Skip to main content

sl_map_apis/
map_tiles.rs

1//! Contains functionality related to fetching map tiles
2use std::path::PathBuf;
3
4use image::GenericImageView as _;
5use sl_types::map::{
6    GridCoordinateOffset, GridCoordinates, GridRectangle, GridRectangleLike, MapTileDescriptor,
7    RegionCoordinates, RegionName, USBNotecard, ZoomFitError, ZoomLevel, ZoomLevelError,
8};
9
10use crate::region::RegionNameToGridCoordinatesCache;
11
12/// represents a map like image, e.g. a map tile or a map that covers
13/// some `GridRectangle` of regions
14pub trait MapLike: GridRectangleLike + image::GenericImage + image::GenericImageView {
15    /// the image of the map
16    #[must_use]
17    fn image(&self) -> &image::DynamicImage;
18
19    /// the mutable image of the map
20    #[must_use]
21    fn image_mut(&mut self) -> &mut image::DynamicImage;
22
23    /// the zoom level of the map
24    #[must_use]
25    fn zoom_level(&self) -> ZoomLevel;
26
27    /// pixels per meter
28    #[must_use]
29    fn pixels_per_meter(&self) -> f32 {
30        self.zoom_level().pixels_per_meter()
31    }
32
33    /// pixels per region
34    #[must_use]
35    fn pixels_per_region(&self) -> f32 {
36        self.pixels_per_meter() * 256f32
37    }
38
39    /// the pixel coordinates in the map that represent the given `GridCoordinates`
40    /// and `RegionCoordinates`
41    #[must_use]
42    fn pixel_coordinates_for_coordinates(
43        &self,
44        grid_coordinates: &GridCoordinates,
45        region_coordinates: &RegionCoordinates,
46    ) -> Option<(u32, u32)> {
47        if !self.contains(grid_coordinates) {
48            return None;
49        }
50        #[expect(
51            clippy::arithmetic_side_effects,
52            reason = "this should never underflow since we already checked with contains that the grid coordinates are inside the map"
53        )]
54        let grid_offset = *grid_coordinates - self.lower_left_corner();
55        #[expect(
56            clippy::cast_possible_truncation,
57            reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
58        )]
59        #[expect(
60            clippy::cast_precision_loss,
61            reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
62        )]
63        #[expect(
64            clippy::cast_sign_loss,
65            reason = "Since grid_offset is the difference between the lower left corner and a coordinate inside the map it is always positive"
66        )]
67        #[expect(
68            clippy::as_conversions,
69            reason = "For the reasons mentioned in the other expects this should be safe here"
70        )]
71        let x = (self.pixels_per_region() * grid_offset.x() as f32
72            + self.pixels_per_meter() * region_coordinates.x()) as u32;
73        #[expect(
74            clippy::cast_possible_truncation,
75            reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
76        )]
77        #[expect(
78            clippy::cast_precision_loss,
79            reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
80        )]
81        #[expect(
82            clippy::cast_sign_loss,
83            reason = "Since grid_offset is the difference between the lower left corner and a coordinate inside the map it is always positive"
84        )]
85        #[expect(
86            clippy::as_conversions,
87            reason = "For the reasons mentioned in the other expects this should be safe here"
88        )]
89        let y = (self.pixels_per_region() * grid_offset.y() as f32
90            + self.pixels_per_meter() * region_coordinates.y()) as u32;
91        #[expect(
92            clippy::arithmetic_side_effects,
93            reason = "since y is a coordinate within the image it should always be less than or equal to height and thus this subtraction should never underflow"
94        )]
95        let y = self.height() - y;
96        Some((x, y))
97    }
98
99    /// the `GridCoordinates` and `RegionCoordinates` at the given pixel coordinates
100    #[must_use]
101    fn coordinates_for_pixel_coordinates(
102        &self,
103        x: u32,
104        y: u32,
105    ) -> Option<(GridCoordinates, RegionCoordinates)> {
106        if !(x <= self.width() && y <= self.height()) {
107            return None;
108        }
109        #[expect(
110            clippy::arithmetic_side_effects,
111            reason = "we just checked that y is less than or equal to height so this can not underflow"
112        )]
113        let y = self.height() - y;
114        #[expect(
115            clippy::arithmetic_side_effects,
116            reason = "we just checked that x and y are less than width and height of this rectangle so this should not overflow if the upper right corner value did not"
117        )]
118        #[expect(
119            clippy::cast_possible_truncation,
120            reason = "we are dealing with grid coordinates so integers are fine"
121        )]
122        #[expect(
123            clippy::cast_precision_loss,
124            reason = "our pixel coordinates are not going to be anywhere near 2^23 or we should rethink our choices of types anyway"
125        )]
126        let grid_result = self.lower_left_corner()
127            + GridCoordinateOffset::new(
128                (x as f32 / self.pixels_per_region()) as i32,
129                (y as f32 / self.pixels_per_region()) as i32,
130            );
131        #[expect(
132            clippy::cast_possible_truncation,
133            reason = "pixels_per_region are always an integer, even if they are represented as f32"
134        )]
135        #[expect(
136            clippy::cast_sign_loss,
137            reason = "pixels_per_region is always positive"
138        )]
139        #[expect(
140            clippy::cast_precision_loss,
141            reason = "x % pixels_per_region should be no larger than 255 (the largest pixels_per_region value is 256)"
142        )]
143        let region_result = RegionCoordinates::new(
144            (x % self.pixels_per_region() as u32) as f32 / self.pixels_per_meter(),
145            (y % self.pixels_per_region() as u32) as f32 / self.pixels_per_meter(),
146            0f32,
147        );
148        Some((grid_result, region_result))
149    }
150
151    /// a crop of the map like image by coordinates and size
152    #[must_use]
153    fn crop_imm_grid_rectangle(
154        &self,
155        grid_rectangle: &GridRectangle,
156    ) -> Option<image::SubImage<&Self>>
157    where
158        Self: Sized,
159    {
160        let lower_left_corner_pixels = self.pixel_coordinates_for_coordinates(
161            &grid_rectangle.lower_left_corner(),
162            &RegionCoordinates::new(0f32, 0f32, 0f32),
163        )?;
164        let upper_right_corner_pixels = self.pixel_coordinates_for_coordinates(
165            &grid_rectangle.upper_right_corner(),
166            &RegionCoordinates::new(256f32, 256f32, 0f32),
167        )?;
168        let x = std::cmp::min(lower_left_corner_pixels.0, upper_right_corner_pixels.0);
169        let y = std::cmp::min(lower_left_corner_pixels.1, upper_right_corner_pixels.1);
170        let width = lower_left_corner_pixels
171            .0
172            .abs_diff(upper_right_corner_pixels.0);
173        let height = lower_left_corner_pixels
174            .1
175            .abs_diff(upper_right_corner_pixels.1);
176        Some(image::imageops::crop_imm(self, x, y, width, height))
177    }
178
179    /// draw a waypoint at the given coordinates
180    fn draw_waypoint(&mut self, x: u32, y: u32, color: image::Rgba<u8>) {
181        #[expect(
182            clippy::cast_possible_wrap,
183            reason = "our pixel coordinates should be nowhere near i32::MAX"
184        )]
185        imageproc::drawing::draw_filled_rect_mut(
186            self.image_mut(),
187            imageproc::rect::Rect::at(x as i32 - 5i32, y as i32 - 5i32).of_size(10, 10),
188            color,
189        );
190    }
191
192    /// draw a line from the given coordinates to the given coordinates
193    fn draw_line(
194        &mut self,
195        from_x: u32,
196        from_y: u32,
197        to_x: u32,
198        to_y: u32,
199        color: image::Rgba<u8>,
200    ) {
201        if from_x == to_x && from_y == to_y {
202            // if the start and the end of the line are identical we do not need to draw anything
203            // also, the division for normalizing below would be a division by 0 in that case
204            return;
205        }
206        #[expect(
207            clippy::cast_precision_loss,
208            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
209        )]
210        let from_x = from_x as f32;
211        #[expect(
212            clippy::cast_precision_loss,
213            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
214        )]
215        let from_y = from_y as f32;
216        #[expect(
217            clippy::cast_precision_loss,
218            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
219        )]
220        let to_x = to_x as f32;
221        #[expect(
222            clippy::cast_precision_loss,
223            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
224        )]
225        let to_y = to_y as f32;
226        let diff = (to_x - from_x, to_y - from_y);
227        let perpendicular = (-diff.1, diff.0);
228        let magnitude = (diff.0.powi(2) + diff.1.powi(2)).sqrt();
229        let perpendicular_normalized = (perpendicular.0 / magnitude, perpendicular.1 / magnitude);
230        #[expect(
231            clippy::cast_possible_truncation,
232            reason = "we want integer coordinates for use in Points"
233        )]
234        let points = vec![
235            imageproc::point::Point::new(
236                (from_x + perpendicular_normalized.0 * 5.0) as i32,
237                (from_y + perpendicular_normalized.1 * 5.0) as i32,
238            ),
239            imageproc::point::Point::new(
240                (to_x + perpendicular_normalized.0 * 5.0) as i32,
241                (to_y + perpendicular_normalized.1 * 5.0) as i32,
242            ),
243            imageproc::point::Point::new(
244                (to_x - perpendicular_normalized.0 * 5.0) as i32,
245                (to_y - perpendicular_normalized.1 * 5.0) as i32,
246            ),
247            imageproc::point::Point::new(
248                (from_x - perpendicular_normalized.0 * 5.0) as i32,
249                (from_y - perpendicular_normalized.1 * 5.0) as i32,
250            ),
251        ];
252        imageproc::drawing::draw_antialiased_polygon_mut(
253            self.image_mut(),
254            &points,
255            color,
256            imageproc::pixelops::interpolate,
257        );
258    }
259
260    /// draw an arrow from the direction of the first point with the
261    /// tip at the second point
262    fn draw_arrow(&mut self, from: (f32, f32), tip: (f32, f32), color: image::Rgba<u8>) {
263        /// length of the arrow at each waypoint from tip to base
264        const ARROW_LENGTH: f32 = 15f32;
265        /// width of the arrow from the center line (double this to get the length of the base side of the triangle)
266        const ARROW_HALF_WIDTH: f32 = 5f32;
267        if from == tip {
268            // do not try to draw arrows from a point to itself
269            return;
270        }
271        let arrow_direction = (tip.0 - from.0, tip.1 - from.1);
272        let arrow_direction_magnitude =
273            (arrow_direction.0.powf(2f32) + arrow_direction.1.powf(2f32)).sqrt();
274        let arrow_direction = (
275            arrow_direction.0 / arrow_direction_magnitude,
276            arrow_direction.1 / arrow_direction_magnitude,
277        );
278        let arrow_base_middle = (
279            tip.0 - (ARROW_LENGTH * arrow_direction.0),
280            tip.1 - (ARROW_LENGTH * arrow_direction.1),
281        );
282        let arrow_base_side1 = (
283            arrow_base_middle.0 + (ARROW_HALF_WIDTH * arrow_direction.1),
284            arrow_base_middle.1 - (ARROW_HALF_WIDTH * arrow_direction.0),
285        );
286        let arrow_base_side2 = (
287            arrow_base_middle.0 - (ARROW_HALF_WIDTH * arrow_direction.1),
288            arrow_base_middle.1 + (ARROW_HALF_WIDTH * arrow_direction.0),
289        );
290        tracing::debug!(
291            "Painting arrow with arrow direction {:?}, arrow tip {:?}, arrow base middle {:?}, arrow_base_side1 {:?}, arrow_base_side2 {:?} ",
292            arrow_direction,
293            tip,
294            arrow_base_middle,
295            arrow_base_side1,
296            arrow_base_side2
297        );
298        #[expect(
299            clippy::cast_possible_truncation,
300            reason = "we want integer coordinates for use in Points"
301        )]
302        imageproc::drawing::draw_polygon_mut(
303            self.image_mut(),
304            &[
305                imageproc::point::Point::new(arrow_base_side1.0 as i32, arrow_base_side1.1 as i32),
306                imageproc::point::Point::new(tip.0 as i32, tip.1 as i32),
307                imageproc::point::Point::new(arrow_base_side2.0 as i32, arrow_base_side2.1 as i32),
308            ],
309            color,
310        );
311    }
312}
313
314/// represents a map tile fetched from the server
315#[derive(Debug, Clone)]
316pub struct MapTile {
317    /// describes the map tile by lower left corner and zoom level
318    descriptor: MapTileDescriptor,
319
320    /// the actual image data
321    image: image::DynamicImage,
322}
323
324impl MapTile {
325    /// the descriptor of the map tile
326    #[must_use]
327    pub const fn descriptor(&self) -> &MapTileDescriptor {
328        &self.descriptor
329    }
330}
331
332impl GridRectangleLike for MapTile {
333    fn grid_rectangle(&self) -> GridRectangle {
334        self.descriptor.grid_rectangle()
335    }
336}
337
338impl image::GenericImageView for MapTile {
339    type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
340
341    fn dimensions(&self) -> (u32, u32) {
342        self.image.dimensions()
343    }
344
345    fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
346        self.image.get_pixel(x, y)
347    }
348}
349
350impl image::GenericImage for MapTile {
351    fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
352        #[expect(
353            deprecated,
354            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
355        )]
356        self.image.get_pixel_mut(x, y)
357    }
358
359    fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
360        self.image.put_pixel(x, y, pixel);
361    }
362
363    fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
364        #[expect(
365            deprecated,
366            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
367        )]
368        self.image.blend_pixel(x, y, pixel);
369    }
370}
371
372impl MapLike for MapTile {
373    fn zoom_level(&self) -> ZoomLevel {
374        self.descriptor.zoom_level().to_owned()
375    }
376
377    fn image(&self) -> &image::DynamicImage {
378        &self.image
379    }
380
381    fn image_mut(&mut self) -> &mut image::DynamicImage {
382        &mut self.image
383    }
384}
385
386/// errors that can happen while fetching a map tile from the cache
387#[derive(Debug, thiserror::Error)]
388pub enum MapTileCacheError {
389    /// error manipulating files in the cache directory
390    #[error("error manipulating files in the cache directory: {0}")]
391    CacheDirectoryFileError(std::io::Error),
392    /// reqwest error when fetching the map tile from the server
393    #[error("reqwest error when fetching the map tile from the server: {0}")]
394    ReqwestError(#[from] reqwest::Error),
395    /// HTTP request is not success
396    #[error("HTTP request is not success: URL {0} response status {1} headers {2:#?} body {3}")]
397    HttpError(
398        String,
399        reqwest::StatusCode,
400        reqwest::header::HeaderMap,
401        String,
402    ),
403    /// failed to clone request for cache policy use (which should not happen
404    /// unless the body is a stream which it is not for us)
405    #[error("failed to clone request for cache policy")]
406    FailedToCloneRequest,
407    /// error guessing image format
408    #[error("error guessing image format: {0}")]
409    ImageFormatGuessError(std::io::Error),
410    /// error reading the raw map tile into an image
411    #[error("error reading the raw map tile into an image: {0}")]
412    ImageError(#[from] image::ImageError),
413    /// error decoding the JSON serialized CachePolicy
414    #[error("error decoding the JSON serialized CachePolicy: {0}")]
415    CachePolicyJsonDecodeError(#[from] serde_json::Error),
416    /// error creating a zoom level
417    #[error("error creating a zoom level: {0}")]
418    ZoomLevelError(#[from] ZoomLevelError),
419    /// error when trying to load cache policy that we previously checked
420    /// existed on disk
421    #[error("error when trying to load cache policy that we previously checked existed on disk")]
422    CachePolicyError,
423}
424
425/// a cache for map tiles on the local filesystem
426#[derive(derive_more::Debug)]
427pub struct MapTileCache {
428    /// the client used to make HTTP requests for map tiles not in the local cache
429    client: reqwest::Client,
430    /// the rate limiter for map tile requests to the server
431    #[debug(skip)]
432    ratelimiter: Option<ratelimit::Ratelimiter>,
433    /// the cache directory
434    cache_directory: PathBuf,
435    /// the in-memory cache
436    #[debug(skip)]
437    cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
438}
439
440/// status of a cache entry on disk
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum MapTileCacheEntryStatus {
443    /// no files at all related to a map tile in the cache
444    Missing,
445    /// an incomplete set of files related to a map tile in the cache
446    Invalid,
447    /// a usable set of files related to a map tile in the cache (cache policy + either a map tile or an absence marker)
448    Valid,
449}
450
451/// a wrapper around response to force status from 403 to 404 for absent map
452/// tiles so `http_cache_semantics::CachePolicy` becomes usable on those responses
453#[derive(Debug)]
454pub struct MapTileNegativeResponse(reqwest::Response);
455
456impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
457    fn status(&self) -> http::status::StatusCode {
458        match self.0.status() {
459            http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
460            status => status,
461        }
462    }
463
464    fn headers(&self) -> &http::header::HeaderMap {
465        self.0.headers()
466    }
467}
468
469impl MapTileCache {
470    /// creates a new `MapTileCache`
471    #[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
472    #[must_use]
473    pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
474        #[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
475        let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
476        Self {
477            client: reqwest::Client::new(),
478            ratelimiter,
479            cache_directory,
480            cache,
481        }
482    }
483
484    /// the file name of a map tile cache file
485    #[must_use]
486    fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
487        format!(
488            "map-{}-{}-{}-objects.jpg",
489            map_tile_descriptor.zoom_level(),
490            map_tile_descriptor.lower_left_corner().x(),
491            map_tile_descriptor.lower_left_corner().y(),
492        )
493    }
494
495    /// the file name of a map tile in the cache directory
496    #[must_use]
497    fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
498        self.cache_directory
499            .join(Self::map_tile_file_name(map_tile_descriptor))
500    }
501
502    /// the file name marking a negative response in the cache directory
503    #[must_use]
504    fn map_tile_cache_negative_response_file_name(
505        &self,
506        map_tile_descriptor: &MapTileDescriptor,
507    ) -> PathBuf {
508        self.cache_directory.join(format!(
509            "{}.does-not-exist",
510            Self::map_tile_file_name(map_tile_descriptor)
511        ))
512    }
513
514    /// the file name of the cache policy file in the cache directory
515    #[must_use]
516    fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
517        self.cache_directory.join(format!(
518            "{}.cache-policy.json",
519            Self::map_tile_file_name(map_tile_descriptor)
520        ))
521    }
522
523    /// the URL of a map tile on the Second Life main map server
524    #[must_use]
525    fn map_tile_url(map_tile_descriptor: &MapTileDescriptor) -> String {
526        format!(
527            "https://secondlife-maps-cdn.akamaized.net/{}",
528            Self::map_tile_file_name(map_tile_descriptor),
529        )
530    }
531
532    /// check if a cache entry is missing, invalid or valid (either cache policy + map tile or cache policy + negative response)
533    async fn cache_entry_status(
534        &self,
535        map_tile_descriptor: &MapTileDescriptor,
536    ) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
537        match (
538            self.cache_policy_file_name(map_tile_descriptor).exists(),
539            self.map_tile_cache_file_name(map_tile_descriptor).exists(),
540            self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
541                .exists(),
542        ) {
543            (false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
544            (true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
545            (cp, tile, neg) => {
546                tracing::warn!(
547                    "cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
548                    cp,
549                    tile,
550                    neg
551                );
552                Ok(MapTileCacheEntryStatus::Invalid)
553            }
554        }
555    }
556
557    /// loads the cached `MapTile` and cache policy from the cache directory
558    /// or from the in-memory LRU cache
559    ///
560    /// # Errors
561    ///
562    /// returns an error if file operations fail
563    async fn fetch_cached_map_tile(
564        &mut self,
565        map_tile_descriptor: &MapTileDescriptor,
566    ) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
567    {
568        if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
569            return Ok(Some(cache_entry.to_owned()));
570        }
571        let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
572        let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
573        if cache_entry_status == MapTileCacheEntryStatus::Invalid {
574            self.remove_cached_tile(map_tile_descriptor).await?;
575            return Ok(None);
576        }
577        if cache_entry_status == MapTileCacheEntryStatus::Missing {
578            return Ok(None);
579        }
580        let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
581            return Err(MapTileCacheError::CachePolicyError);
582        };
583        if cache_file.exists() {
584            let cached_map_tile = image::ImageReader::open(cache_file)
585                .map_err(MapTileCacheError::CacheDirectoryFileError)?
586                .decode()?;
587            Ok(Some((
588                Some(MapTile {
589                    descriptor: map_tile_descriptor.to_owned(),
590                    image: cached_map_tile,
591                }),
592                cache_policy,
593            )))
594        } else {
595            // since we know the cache entry status is valid and no map tile exists we must be dealing with a cached absence
596            Ok(Some((None, cache_policy)))
597        }
598    }
599
600    /// clears the data about a specific map tile from the cache
601    async fn remove_cached_tile(
602        &mut self,
603        map_tile_descriptor: &MapTileDescriptor,
604    ) -> Result<(), MapTileCacheError> {
605        tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
606        self.cache.pop(map_tile_descriptor);
607        let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
608        let cache_file_negative_response =
609            self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
610        let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
611        if cache_file.exists() {
612            std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
613        }
614        if cache_file_negative_response.exists() {
615            std::fs::remove_file(cache_file_negative_response)
616                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
617        }
618        if cache_policy_file.exists() {
619            std::fs::remove_file(cache_policy_file)
620                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
621        }
622        Ok(())
623    }
624
625    /// loads the `http_cache_semantics::CachePolicy` for a cached map tile
626    /// or absence from disk cache
627    ///
628    /// # Errors
629    ///
630    /// returns an error if file operations or JSON deserialization fail
631    async fn load_cache_policy(
632        &self,
633        map_tile_descriptor: &MapTileDescriptor,
634    ) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
635        let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
636        if !cache_policy_file.exists() {
637            return Ok(None);
638        }
639        let cache_policy = std::fs::read_to_string(cache_policy_file)
640            .map_err(MapTileCacheError::CacheDirectoryFileError)?;
641        Ok(serde_json::from_str(&cache_policy)?)
642    }
643
644    /// stores the cache policy in the disk cache
645    ///
646    /// # Errors
647    ///
648    /// returns an error if there was an error in the file operation or when
649    /// serializing the cache policy
650    async fn store_cache_policy(
651        &self,
652        map_tile_descriptor: &MapTileDescriptor,
653        cache_policy: http_cache_semantics::CachePolicy,
654    ) -> Result<(), MapTileCacheError> {
655        if !self.cache_directory.exists() {
656            std::fs::create_dir_all(&self.cache_directory)
657                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
658        }
659        let cache_policy = serde_json::to_string(&cache_policy)?;
660        std::fs::write(
661            self.cache_policy_file_name(map_tile_descriptor),
662            cache_policy,
663        )
664        .map_err(MapTileCacheError::CacheDirectoryFileError)?;
665        Ok(())
666    }
667
668    /// marks a tile as missing in the cache if the cache policy indicates
669    /// it is storable
670    ///
671    /// # Errors
672    ///
673    /// returns an error if there was an error in the file operations
674    /// or serialization of the cache policy
675    async fn cache_missing_tile(
676        &mut self,
677        map_tile_descriptor: &MapTileDescriptor,
678        cache_policy: http_cache_semantics::CachePolicy,
679    ) -> Result<(), MapTileCacheError> {
680        if cache_policy.is_storable() {
681            tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
682            self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
683                .await?;
684            let cache_file_negative_response =
685                self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
686            std::fs::File::create(cache_file_negative_response)
687                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
688            self.cache
689                .put(map_tile_descriptor.clone(), (None, cache_policy));
690        } else {
691            tracing::warn!(
692                "Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
693            );
694        }
695        Ok(())
696    }
697
698    /// stores a tile in the cache if the cache policy indicates that
699    /// it is storable
700    ///
701    /// # Errors
702    ///
703    /// returns an error if there was an error in the file operations
704    /// or serialization of the cache policy
705    async fn cache_tile(
706        &mut self,
707        map_tile_descriptor: &MapTileDescriptor,
708        map_tile: &MapTile,
709        cache_policy: http_cache_semantics::CachePolicy,
710    ) -> Result<(), MapTileCacheError> {
711        if cache_policy.is_storable() {
712            tracing::debug!("Caching map tile {map_tile_descriptor:?}");
713            self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
714                .await?;
715            map_tile
716                .image
717                .save(self.map_tile_cache_file_name(map_tile_descriptor))?;
718            self.cache.put(
719                map_tile_descriptor.clone(),
720                (Some(map_tile.to_owned()), cache_policy),
721            );
722        } else {
723            tracing::warn!(
724                "Map tile {map_tile_descriptor:?} not storable according to cache policy"
725            );
726        }
727        Ok(())
728    }
729
730    /// fetches a map tile from the Second Life main map servers
731    /// or the local cache
732    ///
733    /// # Errors
734    ///
735    /// returns an error if the HTTP request fails of if the result fails to be
736    /// parsed as an image
737    pub async fn get_map_tile(
738        &mut self,
739        map_tile_descriptor: &MapTileDescriptor,
740    ) -> Result<Option<MapTile>, MapTileCacheError> {
741        tracing::debug!("Map tile {map_tile_descriptor:?} requested");
742        let url = Self::map_tile_url(map_tile_descriptor);
743        let request = self.client.get(&url).build()?;
744        let now = std::time::SystemTime::now();
745        if let Some((cached_map_tile, cache_policy)) =
746            self.fetch_cached_map_tile(map_tile_descriptor).await?
747        {
748            if cached_map_tile.is_some() {
749                tracing::debug!("Found matching map tile in cache, checking freshness");
750            } else {
751                tracing::debug!("Found matching map tile absence in cache, checking freshness");
752            }
753            if let http_cache_semantics::BeforeRequest::Fresh(_) =
754                cache_policy.before_request(&request, now)
755            {
756                if cached_map_tile.is_some() {
757                    tracing::debug!("Using cached map tile");
758                } else {
759                    tracing::debug!("Using cached map tile absence");
760                }
761                return Ok(cached_map_tile);
762            }
763            tracing::debug!("Map tile cache not fresh, removing from cache");
764            self.remove_cached_tile(map_tile_descriptor).await?;
765        }
766        tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
767        if let Some(ratelimiter) = &self.ratelimiter {
768            while let Err(duration) = ratelimiter.try_wait() {
769                tokio::time::sleep(duration).await;
770            }
771        }
772        tracing::debug!("Fetching map tile from server at {}", url);
773        let response = self
774            .client
775            .execute(
776                request
777                    .try_clone()
778                    .ok_or(MapTileCacheError::FailedToCloneRequest)?,
779            )
780            .await?;
781        tracing::debug!(
782            "Server response received: status {}, headers\n{:#?}",
783            response.status(),
784            response.headers()
785        );
786        if !response.status().is_success() {
787            if response.status() == reqwest::StatusCode::FORBIDDEN {
788                // FORBIDDEN (403) is returned when the file does not exist
789                // which likely means there is no region/map tile
790                tracing::debug!(
791                    "Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
792                );
793                let cache_policy = http_cache_semantics::CachePolicy::new(
794                    &request,
795                    &MapTileNegativeResponse(response),
796                );
797                self.cache_missing_tile(map_tile_descriptor, cache_policy)
798                    .await?;
799                return Ok(None);
800            }
801            return Err(MapTileCacheError::HttpError(
802                url.to_owned(),
803                response.status(),
804                response.headers().to_owned(),
805                response.text().await?,
806            ));
807        }
808        let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
809        let raw_response_body = response.bytes().await?;
810        tracing::debug!("Parsing received map tile to image");
811        let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
812            .with_guessed_format()
813            .map_err(MapTileCacheError::ImageFormatGuessError)?
814            .decode()?;
815        let map_tile = MapTile {
816            descriptor: map_tile_descriptor.to_owned(),
817            image,
818        };
819        self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
820            .await?;
821        tracing::debug!("Returning freshly fetched map tile");
822        Ok(Some(map_tile))
823    }
824
825    /// figures out if a map tile exist by checking the local in-memory and
826    /// disk caches or fetching the map tile from the server
827    ///
828    /// # Errors
829    ///
830    /// returns an error if fetching the map tile from cache or remotely fails
831    pub async fn does_map_tile_exist(
832        &mut self,
833        map_tile_descriptor: &MapTileDescriptor,
834    ) -> Result<bool, MapTileCacheError> {
835        let url = Self::map_tile_url(map_tile_descriptor);
836        if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
837            let request = self.client.get(&url).build()?;
838            let now = std::time::SystemTime::now();
839            if let http_cache_semantics::BeforeRequest::Fresh(_) =
840                cache_policy.before_request(&request, now)
841            {
842                return Ok(map_tile.is_some());
843            }
844        }
845        if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
846            && let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
847        {
848            let request = self.client.get(&url).build()?;
849            let now = std::time::SystemTime::now();
850            if let http_cache_semantics::BeforeRequest::Fresh(_) =
851                cache_policy.before_request(&request, now)
852            {
853                if self
854                    .map_tile_cache_negative_response_file_name(map_tile_descriptor)
855                    .exists()
856                {
857                    return Ok(false);
858                }
859                return Ok(true);
860            }
861        }
862        Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
863    }
864
865    /// figures out if a region exists based on the existence of map tiles for it, starting with the lowest zoom level
866    /// and potentially going up to the highest one if all the other zoom levels have a tile for that region
867    ///
868    /// # Errors
869    ///
870    /// returns an error if fetching map tiles from cache or remotely fails
871    pub async fn does_region_exist(
872        &mut self,
873        grid_coordinates: &GridCoordinates,
874    ) -> Result<bool, MapTileCacheError> {
875        for zoom_level in (1..=8).rev() {
876            tracing::debug!(
877                "Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
878            );
879            let map_tile_descriptor = MapTileDescriptor::new(
880                ZoomLevel::try_new(zoom_level)?,
881                grid_coordinates.to_owned(),
882            );
883            if !self.does_map_tile_exist(&map_tile_descriptor).await? {
884                tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
885                return Ok(false);
886            }
887            let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
888            if cache_entry_status == MapTileCacheEntryStatus::Valid {}
889        }
890        tracing::debug!(
891            "Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
892        );
893        Ok(true)
894    }
895}
896
897/// represents a map assembled from map tiles
898#[derive(Debug, Clone)]
899pub struct Map {
900    /// the zoom level of this map
901    zoom_level: ZoomLevel,
902    /// the grid rectangle of regions represented by this map
903    grid_rectangle: GridRectangle,
904    /// the actual map image
905    image: image::DynamicImage,
906}
907
908/// represents errors that can occur while creating a map
909#[derive(Debug, thiserror::Error)]
910pub enum MapError {
911    /// an error in the map tile cache
912    #[error("error in map tile cache while assembling map: {0}")]
913    MapTileCacheError(#[from] MapTileCacheError),
914    /// an error occurred when trying to calculate the zoom level that fits the
915    /// map grid rectangle into the output image
916    #[error(
917        "error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
918    )]
919    ZoomFitError(#[from] ZoomFitError),
920    /// failed to crop a map tile to the required size
921    #[error("error when cropping a map tile to the required size")]
922    MapTileCropError,
923    /// failed to calculate pixel coordinates where we want to place a map tile crop
924    #[error("error when calculating pixel coordinates where we want to place a map tile crop")]
925    MapCoordinateError,
926    /// no overlap between map tile we fetched and output map (should not happen)
927    #[error("no overlap between map tile we fetched and output map (should not happen)")]
928    NoOverlapError,
929    /// no grid coordinates were returned for one of the region names in the
930    /// USB Notecard
931    #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
932    NoGridCoordinatesForRegion(RegionName),
933    /// error in region name to grid coordinate cache
934    #[error("error in region name to grid coordinate cache: {0}")]
935    RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
936    /// error calculating spline
937    #[error("error calculating spline: {0}")]
938    SplineError(
939        #[source]
940        #[from]
941        uniform_cubic_splines::SplineError,
942    ),
943}
944
945impl Map {
946    /// creates a new `Map`
947    ///
948    /// if we choose not to fill the missing map tiles they appear as black
949    ///
950    /// if we choose not to fill the missing regions they appear in a color
951    /// similar to water but filling them in has some performance impact since
952    /// we need to check if the region exists by fetching higher resolution
953    /// map tiles for it.
954    ///
955    /// # Errors
956    ///
957    /// returns an error if fetching the map tiles fails
958    ///
959    /// # Arguments
960    ///
961    /// * `map_tile_cache` - the map tile cache to use to fetch the map tiles
962    /// * `x` - the width of the map in pixels
963    /// * `y` - the height of the map in pixels
964    /// * `grid_rectangle` - the grid rectangle of regions represented by this map
965    pub async fn new(
966        map_tile_cache: &mut MapTileCache,
967        x: u32,
968        y: u32,
969        grid_rectangle: GridRectangle,
970        fill_missing_map_tiles: Option<image::Rgba<u8>>,
971        fill_missing_regions: Option<image::Rgba<u8>>,
972    ) -> Result<Self, MapError> {
973        let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
974            grid_rectangle.size_x(),
975            grid_rectangle.size_y(),
976            x,
977            y,
978        )?;
979        let actual_x = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
980            * <u16 as Into<u32>>::into(grid_rectangle.size_x());
981        let actual_y = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
982            * <u16 as Into<u32>>::into(grid_rectangle.size_y());
983        tracing::debug!(
984            "Determined max zoom level for map of size ({x}, {y}) for {grid_rectangle:?} to be {zoom_level:?}, actual map size will be ({actual_x}, {actual_y})"
985        );
986        let x = actual_x;
987        let y = actual_y;
988        let image = image::DynamicImage::new_rgb8(x, y);
989        let mut result = Self {
990            zoom_level,
991            grid_rectangle,
992            image,
993        };
994        for region_x in result.x_range() {
995            for region_y in result.y_range() {
996                let grid_coordinates = GridCoordinates::new(region_x, region_y);
997                let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
998                let Some(overlap) = result.intersect(&map_tile_descriptor) else {
999                    return Err(MapError::NoOverlapError);
1000                };
1001                if overlap.lower_left_corner().x() != region_x
1002                    || overlap.lower_left_corner().y() != region_y
1003                {
1004                    // we should have already processed this map tile when
1005                    // we encountered the lower left corner of the overlap
1006                    continue;
1007                }
1008                tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1009                if let Some(map_tile) = map_tile_cache.get_map_tile(&map_tile_descriptor).await? {
1010                    let crop = map_tile
1011                        .crop_imm_grid_rectangle(&overlap)
1012                        .ok_or(MapError::MapTileCropError)?;
1013                    tracing::debug!(
1014                        "Cropped map tile to ({}, {})+{}x{}",
1015                        crop.offsets().0,
1016                        crop.offsets().1,
1017                        (*crop).dimensions().0,
1018                        (*crop).dimensions().1
1019                    );
1020                    // we need to use y = 256 here since the crop is inserted by pixel coordinates which means
1021                    // we need the upper left corner, not the lower left one of the region as an origin
1022                    let (replace_x, replace_y) = result
1023                        .pixel_coordinates_for_coordinates(
1024                            &overlap.upper_left_corner(),
1025                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1026                        )
1027                        .ok_or(MapError::MapCoordinateError)?;
1028                    tracing::debug!(
1029                        "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1030                    );
1031                    image::imageops::replace(
1032                        &mut result,
1033                        &*crop,
1034                        replace_x.into(),
1035                        replace_y.into(),
1036                    );
1037                    if let Some(fill_color) = fill_missing_regions {
1038                        for overlap_region_x in overlap.x_range() {
1039                            for overlap_region_y in overlap.y_range() {
1040                                let grid_coordinates =
1041                                    GridCoordinates::new(overlap_region_x, overlap_region_y);
1042                                if !map_tile_cache.does_region_exist(&grid_coordinates).await? {
1043                                    let pixel_min = result.pixel_coordinates_for_coordinates(
1044                                        &grid_coordinates,
1045                                        &RegionCoordinates::new(0f32, 256f32, 0f32),
1046                                    );
1047                                    let pixel_max = result.pixel_coordinates_for_coordinates(
1048                                        &grid_coordinates,
1049                                        &RegionCoordinates::new(256f32, 0f32, 0f32),
1050                                    );
1051                                    if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1052                                        (pixel_min, pixel_max)
1053                                    {
1054                                        for x in min_x..max_x {
1055                                            for y in min_y..max_y {
1056                                                <Self as image::GenericImage>::put_pixel(
1057                                                    &mut result,
1058                                                    x,
1059                                                    y,
1060                                                    fill_color,
1061                                                );
1062                                            }
1063                                        }
1064                                    }
1065                                }
1066                            }
1067                        }
1068                    }
1069                } else if let Some(fill_color) = fill_missing_map_tiles {
1070                    let (replace_x, replace_y) = result
1071                        .pixel_coordinates_for_coordinates(
1072                            &overlap.upper_left_corner(),
1073                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1074                        )
1075                        .ok_or(MapError::MapCoordinateError)?;
1076                    let pixel_size_x =
1077                        u32::from(overlap.size_x()) * u32::from(zoom_level.pixels_per_region());
1078                    let pixel_size_y =
1079                        u32::from(overlap.size_y()) * u32::from(zoom_level.pixels_per_region());
1080                    for x in replace_x..replace_x + pixel_size_x {
1081                        for y in replace_y..replace_y + pixel_size_y {
1082                            <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1083                        }
1084                    }
1085                }
1086            }
1087        }
1088        Ok(result)
1089    }
1090
1091    /// draws a route from a `USBNotecard` onto the map
1092    ///
1093    /// # Errors
1094    ///
1095    /// fails if the region name to grid coordinate conversion fails
1096    /// or the conversion of those into pixel coordinates
1097    pub async fn draw_route(
1098        &mut self,
1099        region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1100        usb_notecard: &USBNotecard,
1101        color: image::Rgba<u8>,
1102    ) -> Result<(), MapError> {
1103        tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1104        let mut pixel_waypoints = Vec::new();
1105        for waypoint in usb_notecard.waypoints() {
1106            let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1107                .get_grid_coordinates(waypoint.location().region_name())
1108                .await?
1109            else {
1110                return Err(MapError::NoGridCoordinatesForRegion(
1111                    waypoint.location().region_name().to_owned(),
1112                ));
1113            };
1114            let (x, y) = self
1115                .pixel_coordinates_for_coordinates(
1116                    &grid_coordinates,
1117                    &waypoint.region_coordinates(),
1118                )
1119                .ok_or(MapError::MapCoordinateError)?;
1120            tracing::debug!(
1121                "Drawing waypoint at ({x}, {y}) for location {:?}",
1122                waypoint.location()
1123            );
1124            //self.draw_waypoint(x, y, color);
1125            #[expect(
1126                clippy::cast_precision_loss,
1127                reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1128            )]
1129            pixel_waypoints.push((x as f32, y as f32));
1130        }
1131        let waypoint_count = pixel_waypoints.len();
1132        let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1133            // no route if there are no waypoints
1134            return Ok(());
1135        };
1136        let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1137        else {
1138            // no route if there is only one waypoint
1139            return Ok(());
1140        };
1141        let extra_before_start = (
1142            first.0 - (second.0 - first.0),
1143            first.1 - (second.1 - first.1),
1144        );
1145        let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1146            // no route if there are no waypoints (but this should never happen since we already returned at the first split_first() above)
1147            return Ok(());
1148        };
1149        let Some((second_to_last, _pixel_waypoints_rest)) =
1150            pixel_waypoints_all_but_last.split_last()
1151        else {
1152            // no route if there is only one waypoint (but this should never happen since we already returned at the second split_first() above)
1153            return Ok(());
1154        };
1155        let extra_after_end = (
1156            last.0 + (last.0 - second_to_last.0),
1157            last.1 + (last.1 - second_to_last.1),
1158        );
1159        let mut knots = vec![extra_before_start];
1160        knots.extend(pixel_waypoints.to_owned());
1161        knots.push(extra_after_end);
1162        let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1163        let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1164            let point_x =
1165                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1166                    v, &points_x,
1167                )?;
1168            let point_y =
1169                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1170                    v, &points_y,
1171                )?;
1172            Ok((point_x, point_y))
1173        };
1174        #[expect(
1175            clippy::cast_precision_loss,
1176            reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1177        )]
1178        let spline_value_for_waypoint =
1179            |i: usize| -> f32 { i as f32 / (waypoint_count as f32 - 2f32) };
1180        let spline_value_between_waypoints = spline_value_for_waypoint(1);
1181        let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1182            ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1183        };
1184        let mut last_point: Option<(f32, f32)> = None;
1185        for (i, waypoint) in pixel_waypoints.iter().enumerate().take(waypoint_count - 1) {
1186            /// size of rectangles to use to draw the spline, should be odd
1187            /// or it won't be centered properly
1188            const SPLINE_RECT_SIZE: u8 = 3;
1189            tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1190            let v = spline_value_for_waypoint(i);
1191            let point = sample(v)?;
1192            tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1193            if let Some(last_point) = last_point {
1194                let distance_from_last_point = distance_between_points(point, last_point);
1195                tracing::debug!(
1196                    "Waypoint {i} is {:?} from last waypoint",
1197                    distance_from_last_point
1198                );
1199                #[expect(
1200                    clippy::cast_possible_truncation,
1201                    reason = "we want an integer count for the number of samples"
1202                )]
1203                #[expect(
1204                    clippy::cast_sign_loss,
1205                    reason = "we want a positive count for the number of samples"
1206                )]
1207                let samples_between_last_waypoint_and_this_one =
1208                    (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1209                for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1210                    #[expect(
1211                        clippy::cast_precision_loss,
1212                        reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1213                    )]
1214                    let v = v - spline_value_between_waypoints
1215                        * (j as f32 / (samples_between_last_waypoint_and_this_one as f32 - 2f32));
1216                    let sample_point = sample(v)?;
1217                    #[expect(
1218                        clippy::cast_possible_truncation,
1219                        reason = "we want integer pixel coordinates for use in the image library"
1220                    )]
1221                    imageproc::drawing::draw_filled_rect_mut(
1222                        self.image_mut(),
1223                        imageproc::rect::Rect::at(
1224                            sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1225                            sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1226                        )
1227                        .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1228                        color,
1229                    );
1230                }
1231                self.draw_arrow(
1232                    sample(v - (0.1f32 * spline_value_between_waypoints))?,
1233                    point,
1234                    color,
1235                );
1236            }
1237            last_point = Some(point);
1238        }
1239        Ok(())
1240    }
1241
1242    /// saves the map to the specified path
1243    ///
1244    /// # Errors
1245    ///
1246    /// returns an error when the image libraries returns an error
1247    /// when saving the image
1248    pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1249        self.image.save(path)
1250    }
1251}
1252
1253impl GridRectangleLike for Map {
1254    fn grid_rectangle(&self) -> GridRectangle {
1255        self.grid_rectangle.to_owned()
1256    }
1257}
1258
1259impl image::GenericImageView for Map {
1260    type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1261
1262    fn dimensions(&self) -> (u32, u32) {
1263        self.image.dimensions()
1264    }
1265
1266    fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1267        self.image.get_pixel(x, y)
1268    }
1269}
1270
1271impl image::GenericImage for Map {
1272    fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1273        #[expect(
1274            deprecated,
1275            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1276        )]
1277        self.image.get_pixel_mut(x, y)
1278    }
1279
1280    fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1281        self.image.put_pixel(x, y, pixel);
1282    }
1283
1284    fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1285        #[expect(
1286            deprecated,
1287            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1288        )]
1289        self.image.blend_pixel(x, y, pixel);
1290    }
1291}
1292
1293impl MapLike for Map {
1294    fn zoom_level(&self) -> ZoomLevel {
1295        self.zoom_level
1296    }
1297
1298    fn image(&self) -> &image::DynamicImage {
1299        &self.image
1300    }
1301
1302    fn image_mut(&mut self) -> &mut image::DynamicImage {
1303        &mut self.image
1304    }
1305}
1306
1307#[cfg(test)]
1308mod test {
1309    use image::GenericImageView as _;
1310    use tracing_test::traced_test;
1311
1312    use super::*;
1313
1314    #[tokio::test]
1315    async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1316        let temp_dir = tempfile::tempdir()?;
1317        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1318        map_tile_cache
1319            .get_map_tile(&MapTileDescriptor::new(
1320                ZoomLevel::try_new(1)?,
1321                GridCoordinates::new(1136, 1075),
1322            ))
1323            .await?;
1324        Ok(())
1325    }
1326
1327    #[tokio::test]
1328    async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1329        let temp_dir = tempfile::tempdir()?;
1330        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1331        map_tile_cache
1332            .get_map_tile(&MapTileDescriptor::new(
1333                ZoomLevel::try_new(1)?,
1334                GridCoordinates::new(1136, 1075),
1335            ))
1336            .await?;
1337        map_tile_cache
1338            .get_map_tile(&MapTileDescriptor::new(
1339                ZoomLevel::try_new(1)?,
1340                GridCoordinates::new(1136, 1075),
1341            ))
1342            .await?;
1343        Ok(())
1344    }
1345
1346    #[tokio::test]
1347    async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1348        let temp_dir = tempfile::tempdir()?;
1349        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1350        map_tile_cache
1351            .get_map_tile(&MapTileDescriptor::new(
1352                ZoomLevel::try_new(8)?,
1353                GridCoordinates::new(1136, 1075),
1354            ))
1355            .await?;
1356        Ok(())
1357    }
1358
1359    #[traced_test]
1360    #[tokio::test]
1361    async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1362        let temp_dir = tempfile::tempdir()?;
1363        let ratelimiter =
1364            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1365        let mut map_tile_cache =
1366            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1367        let map = Map::new(
1368            &mut map_tile_cache,
1369            512,
1370            512,
1371            GridRectangle::new(
1372                GridCoordinates::new(1135, 1070),
1373                GridCoordinates::new(1136, 1071),
1374            ),
1375            None,
1376            None,
1377        )
1378        .await?;
1379        map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1380        Ok(())
1381    }
1382
1383    #[traced_test]
1384    #[tokio::test]
1385    async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1386        let temp_dir = tempfile::tempdir()?;
1387        let ratelimiter =
1388            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1389        let mut map_tile_cache =
1390            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1391        let map = Map::new(
1392            &mut map_tile_cache,
1393            256,
1394            256,
1395            GridRectangle::new(
1396                GridCoordinates::new(1136, 1074),
1397                GridCoordinates::new(1137, 1075),
1398            ),
1399            None,
1400            None,
1401        )
1402        .await?;
1403        map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1404        Ok(())
1405    }
1406
1407    #[traced_test]
1408    #[tokio::test]
1409    async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1410        let temp_dir = tempfile::tempdir()?;
1411        let ratelimiter =
1412            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1413        let mut map_tile_cache =
1414            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1415        let map = Map::new(
1416            &mut map_tile_cache,
1417            128,
1418            128,
1419            GridRectangle::new(
1420                GridCoordinates::new(1136, 1074),
1421                GridCoordinates::new(1137, 1075),
1422            ),
1423            None,
1424            None,
1425        )
1426        .await?;
1427        map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1428        Ok(())
1429    }
1430
1431    #[traced_test]
1432    #[tokio::test]
1433    async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1434        let temp_dir = tempfile::tempdir()?;
1435        let ratelimiter =
1436            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_millis(100)).build()?;
1437        let mut map_tile_cache =
1438            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1439        let map = Map::new(
1440            &mut map_tile_cache,
1441            2048,
1442            2048,
1443            GridRectangle::new(
1444                GridCoordinates::new(1131, 1068),
1445                GridCoordinates::new(1139, 1075),
1446            ),
1447            None,
1448            None,
1449        )
1450        .await?;
1451        map.save(std::path::Path::new(
1452            "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1453        ))?;
1454        Ok(())
1455    }
1456
1457    #[traced_test]
1458    #[tokio::test]
1459    #[expect(clippy::panic, reason = "panic in test is intentional")]
1460    async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1461    -> Result<(), Box<dyn std::error::Error>> {
1462        let temp_dir = tempfile::tempdir()?;
1463        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1464        let Some(map_tile) = map_tile_cache
1465            .get_map_tile(&MapTileDescriptor::new(
1466                ZoomLevel::try_new(1)?,
1467                GridCoordinates::new(1136, 1075),
1468            ))
1469            .await?
1470        else {
1471            panic!("Expected there to be a region at this location");
1472        };
1473        for in_region_x in 0..=256 {
1474            for in_region_y in 0..=256 {
1475                let grid_coordinates = GridCoordinates::new(1136, 1075);
1476                #[expect(
1477                    clippy::cast_precision_loss,
1478                    reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1479                )]
1480                let region_coordinates =
1481                    RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
1482                tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
1483                assert_eq!(
1484                    map_tile
1485                        .pixel_coordinates_for_coordinates(&grid_coordinates, &region_coordinates,),
1486                    Some((in_region_x, 256 - in_region_y)),
1487                );
1488            }
1489        }
1490        Ok(())
1491    }
1492
1493    #[traced_test]
1494    #[tokio::test]
1495    async fn test_map_pixel_coordinates_for_coordinates_four_regions()
1496    -> Result<(), Box<dyn std::error::Error>> {
1497        let temp_dir = tempfile::tempdir()?;
1498        let ratelimiter =
1499            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1500        let mut map_tile_cache =
1501            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1502        let map = Map::new(
1503            &mut map_tile_cache,
1504            512,
1505            512,
1506            GridRectangle::new(
1507                GridCoordinates::new(1136, 1074),
1508                GridCoordinates::new(1137, 1075),
1509            ),
1510            None,
1511            None,
1512        )
1513        .await?;
1514        for region_offset_x in 0..=1 {
1515            for region_offset_y in 0..=1 {
1516                for in_region_x in 0..=256 {
1517                    for in_region_y in 0..=256 {
1518                        let grid_coordinates =
1519                            GridCoordinates::new(1136 + region_offset_x, 1074 + region_offset_y);
1520                        let region_coordinates = RegionCoordinates::new(
1521                            f32::from(in_region_x),
1522                            f32::from(in_region_y),
1523                            0f32,
1524                        );
1525                        tracing::debug!(
1526                            "Now checking {grid_coordinates:?}, {region_coordinates:?}"
1527                        );
1528                        assert_eq!(
1529                            map.pixel_coordinates_for_coordinates(
1530                                &grid_coordinates,
1531                                &region_coordinates,
1532                            ),
1533                            Some((
1534                                u32::from(region_offset_x * 256 + in_region_x),
1535                                u32::from(512 - (region_offset_y * 256 + in_region_y))
1536                            )),
1537                        );
1538                    }
1539                }
1540            }
1541        }
1542        Ok(())
1543    }
1544
1545    #[traced_test]
1546    #[tokio::test]
1547    #[expect(clippy::panic, reason = "panic in test is intentional")]
1548    async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
1549    -> Result<(), Box<dyn std::error::Error>> {
1550        let temp_dir = tempfile::tempdir()?;
1551        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1552        let Some(map_tile) = map_tile_cache
1553            .get_map_tile(&MapTileDescriptor::new(
1554                ZoomLevel::try_new(1)?,
1555                GridCoordinates::new(1136, 1075),
1556            ))
1557            .await?
1558        else {
1559            panic!("Expected there to be a region at this location");
1560        };
1561        tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
1562        #[expect(
1563            clippy::cast_precision_loss,
1564            reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1565        )]
1566        for in_region_x in 0..=256 {
1567            for in_region_y in 0..=256 {
1568                let pixel_x = in_region_x;
1569                let pixel_y = 256 - in_region_y;
1570                tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
1571                assert_eq!(
1572                    map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
1573                    Some((
1574                        GridCoordinates::new(
1575                            1136 + if in_region_x == 256 { 1 } else { 0 },
1576                            1075 + if in_region_y == 256 { 1 } else { 0 }
1577                        ),
1578                        RegionCoordinates::new(
1579                            (in_region_x % 256) as f32,
1580                            (in_region_y % 256) as f32,
1581                            0f32
1582                        ),
1583                    ))
1584                );
1585            }
1586        }
1587        Ok(())
1588    }
1589
1590    #[traced_test]
1591    #[tokio::test]
1592    async fn test_map_coordinates_for_pixel_coordinates_four_regions()
1593    -> Result<(), Box<dyn std::error::Error>> {
1594        let temp_dir = tempfile::tempdir()?;
1595        let ratelimiter =
1596            ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1597        let mut map_tile_cache =
1598            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1599        let map = Map::new(
1600            &mut map_tile_cache,
1601            512,
1602            512,
1603            GridRectangle::new(
1604                GridCoordinates::new(1136, 1074),
1605                GridCoordinates::new(1137, 1075),
1606            ),
1607            None,
1608            None,
1609        )
1610        .await?;
1611        tracing::debug!("Dimensions of map are {:?}", map.dimensions());
1612        for region_offset_x in 0..=1 {
1613            for region_offset_y in 0..=1 {
1614                for in_region_x in 0..=256 {
1615                    for in_region_y in 0..=256 {
1616                        let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
1617                        let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
1618                        tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
1619                        assert_eq!(
1620                            map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
1621                            Some((
1622                                GridCoordinates::new(
1623                                    1136 + region_offset_x + if in_region_x == 256 { 1 } else { 0 },
1624                                    1074 + region_offset_y + if in_region_y == 256 { 1 } else { 0 }
1625                                ),
1626                                RegionCoordinates::new(
1627                                    f32::from(in_region_x % 256),
1628                                    f32::from(in_region_y % 256),
1629                                    0f32
1630                                ),
1631                            )),
1632                        );
1633                    }
1634                }
1635            }
1636        }
1637        Ok(())
1638    }
1639}