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 hollow (1px outline) rectangle with its top-left corner at the
193    /// given pixel coordinates and the given pixel size. Coordinates outside
194    /// the image are clipped by the drawing routine. Used for the optional
195    /// per-region grid overlay.
196    fn draw_hollow_rect(
197        &mut self,
198        x: u32,
199        y: u32,
200        width: u32,
201        height: u32,
202        color: image::Rgba<u8>,
203    ) {
204        if width == 0 || height == 0 {
205            return;
206        }
207        #[expect(
208            clippy::cast_possible_wrap,
209            reason = "our pixel coordinates should be nowhere near i32::MAX"
210        )]
211        let rect = imageproc::rect::Rect::at(x as i32, y as i32).of_size(width, height);
212        imageproc::drawing::draw_hollow_rect_mut(self.image_mut(), rect, color);
213    }
214
215    /// fill a solid rectangle with its top-left corner at the given pixel
216    /// coordinates and the given pixel size. Coordinates outside the image are
217    /// clipped by the drawing routine. Used for the optional per-region
218    /// missing-region fill overlay.
219    fn draw_filled_rect(
220        &mut self,
221        x: u32,
222        y: u32,
223        width: u32,
224        height: u32,
225        color: image::Rgba<u8>,
226    ) {
227        if width == 0 || height == 0 {
228            return;
229        }
230        #[expect(
231            clippy::cast_possible_wrap,
232            reason = "our pixel coordinates should be nowhere near i32::MAX"
233        )]
234        let rect = imageproc::rect::Rect::at(x as i32, y as i32).of_size(width, height);
235        imageproc::drawing::draw_filled_rect_mut(self.image_mut(), rect, color);
236    }
237
238    /// draw a line from the given coordinates to the given coordinates
239    fn draw_line(
240        &mut self,
241        from_x: u32,
242        from_y: u32,
243        to_x: u32,
244        to_y: u32,
245        color: image::Rgba<u8>,
246    ) {
247        if from_x == to_x && from_y == to_y {
248            // if the start and the end of the line are identical we do not need to draw anything
249            // also, the division for normalizing below would be a division by 0 in that case
250            return;
251        }
252        #[expect(
253            clippy::cast_precision_loss,
254            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
255        )]
256        let from_x = from_x as f32;
257        #[expect(
258            clippy::cast_precision_loss,
259            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
260        )]
261        let from_y = from_y as f32;
262        #[expect(
263            clippy::cast_precision_loss,
264            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
265        )]
266        let to_x = to_x as f32;
267        #[expect(
268            clippy::cast_precision_loss,
269            reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
270        )]
271        let to_y = to_y as f32;
272        let diff = (to_x - from_x, to_y - from_y);
273        let perpendicular = (-diff.1, diff.0);
274        let magnitude = (diff.0.powi(2) + diff.1.powi(2)).sqrt();
275        let perpendicular_normalized = (perpendicular.0 / magnitude, perpendicular.1 / magnitude);
276        #[expect(
277            clippy::cast_possible_truncation,
278            reason = "we want integer coordinates for use in Points"
279        )]
280        let points = vec![
281            imageproc::point::Point::new(
282                (from_x + perpendicular_normalized.0 * 5.0) as i32,
283                (from_y + perpendicular_normalized.1 * 5.0) as i32,
284            ),
285            imageproc::point::Point::new(
286                (to_x + perpendicular_normalized.0 * 5.0) as i32,
287                (to_y + perpendicular_normalized.1 * 5.0) as i32,
288            ),
289            imageproc::point::Point::new(
290                (to_x - perpendicular_normalized.0 * 5.0) as i32,
291                (to_y - perpendicular_normalized.1 * 5.0) as i32,
292            ),
293            imageproc::point::Point::new(
294                (from_x - perpendicular_normalized.0 * 5.0) as i32,
295                (from_y - perpendicular_normalized.1 * 5.0) as i32,
296            ),
297        ];
298        imageproc::drawing::draw_antialiased_polygon_mut(
299            self.image_mut(),
300            &points,
301            color,
302            imageproc::pixelops::interpolate,
303        );
304    }
305
306    /// draw an arrow from the direction of the first point with the
307    /// tip at the second point
308    fn draw_arrow(&mut self, from: (f32, f32), tip: (f32, f32), color: image::Rgba<u8>) {
309        /// length of the arrow at each waypoint from tip to base
310        const ARROW_LENGTH: f32 = 15f32;
311        /// width of the arrow from the center line (double this to get the length of the base side of the triangle)
312        const ARROW_HALF_WIDTH: f32 = 5f32;
313        if from == tip {
314            // do not try to draw arrows from a point to itself
315            return;
316        }
317        let arrow_direction = (tip.0 - from.0, tip.1 - from.1);
318        let arrow_direction_magnitude =
319            (arrow_direction.0.powf(2f32) + arrow_direction.1.powf(2f32)).sqrt();
320        let arrow_direction = (
321            arrow_direction.0 / arrow_direction_magnitude,
322            arrow_direction.1 / arrow_direction_magnitude,
323        );
324        let arrow_base_middle = (
325            tip.0 - (ARROW_LENGTH * arrow_direction.0),
326            tip.1 - (ARROW_LENGTH * arrow_direction.1),
327        );
328        let arrow_base_side1 = (
329            arrow_base_middle.0 + (ARROW_HALF_WIDTH * arrow_direction.1),
330            arrow_base_middle.1 - (ARROW_HALF_WIDTH * arrow_direction.0),
331        );
332        let arrow_base_side2 = (
333            arrow_base_middle.0 - (ARROW_HALF_WIDTH * arrow_direction.1),
334            arrow_base_middle.1 + (ARROW_HALF_WIDTH * arrow_direction.0),
335        );
336        tracing::debug!(
337            "Painting arrow with arrow direction {:?}, arrow tip {:?}, arrow base middle {:?}, arrow_base_side1 {:?}, arrow_base_side2 {:?} ",
338            arrow_direction,
339            tip,
340            arrow_base_middle,
341            arrow_base_side1,
342            arrow_base_side2
343        );
344        #[expect(
345            clippy::cast_possible_truncation,
346            reason = "we want integer coordinates for use in Points"
347        )]
348        imageproc::drawing::draw_polygon_mut(
349            self.image_mut(),
350            &[
351                imageproc::point::Point::new(arrow_base_side1.0 as i32, arrow_base_side1.1 as i32),
352                imageproc::point::Point::new(tip.0 as i32, tip.1 as i32),
353                imageproc::point::Point::new(arrow_base_side2.0 as i32, arrow_base_side2.1 as i32),
354            ],
355            color,
356        );
357    }
358
359    /// draw an arbitrary multi-line text label with a drop shadow at the given
360    /// top-left pixel `origin`, using a caller-supplied font and
361    /// [`crate::text::LabelStyle`]. Lines stack downward. This is the building
362    /// block for free-floating labels (legends, logos captions, manual labels)
363    /// that are not tied to any particular overlay.
364    fn draw_text_label<F: ab_glyph::Font>(
365        &mut self,
366        origin: (i32, i32),
367        lines: &[String],
368        style: &crate::text::LabelStyle,
369        font: &F,
370    ) {
371        crate::text::draw_multi_line_with_shadow(self, origin.0, origin.1, style, font, lines);
372    }
373}
374
375/// where a map tile came from when fetched through the cache
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum TileOutcome {
378    /// found in the in-memory LRU cache
379    LoadedFromMemoryCache,
380    /// found in the on-disk cache
381    LoadedFromDiskCache,
382    /// fetched from the upstream server
383    FetchedFromNetwork,
384    /// the tile is known to not exist (either fetched and got 403,
385    /// or a cached absence)
386    Missing,
387}
388
389/// events emitted during map rendering for progress reporting
390#[derive(Debug, Clone)]
391pub enum MapProgressEvent {
392    /// the rendering plan has been computed, lists the chosen zoom level
393    /// and the total number of map tiles that will be processed
394    PlanComputed {
395        /// the zoom level chosen for this render
396        zoom_level: ZoomLevel,
397        /// the total number of map tiles that will be processed
398        total_tiles: u32,
399    },
400    /// processing of a tile has started
401    TileStarted {
402        /// the descriptor of the tile being processed
403        descriptor: MapTileDescriptor,
404    },
405    /// processing of a tile has finished
406    TileFinished {
407        /// the descriptor of the tile that was processed
408        descriptor: MapTileDescriptor,
409        /// where the tile came from
410        outcome: TileOutcome,
411    },
412    /// the renderer will check every region inside each present tile for
413    /// existence (this only happens when `fill_missing_regions` is set;
414    /// each check may trigger several upstream fetches for higher-zoom
415    /// tiles, so this phase often dominates the wall-clock time)
416    RegionCheckPlanned {
417        /// upper bound on how many region checks will be performed; the
418        /// actual count can be lower if some primary tiles turn out to be
419        /// missing entirely (those regions get the missing-tile colour
420        /// instead of an individual check)
421        total_regions: u32,
422    },
423    /// a region's existence has been determined
424    RegionChecked {
425        /// x grid coordinate of the region
426        x: u16,
427        /// y grid coordinate of the region
428        y: u16,
429        /// whether the region was found on at least one zoom level
430        exists: bool,
431    },
432    /// the route drawing has been planned, lists the total number of
433    /// waypoints to process
434    RoutePlanned {
435        /// total number of waypoints in the route
436        total_waypoints: usize,
437    },
438    /// a waypoint in a route has been resolved to grid coordinates
439    RouteWaypointResolved {
440        /// the index of this waypoint (0-based)
441        index: usize,
442        /// the total number of waypoints
443        total: usize,
444        /// the region name of the waypoint
445        region: RegionName,
446    },
447    /// resolving region names for the per-region annotation overlay is about to
448    /// start, lists the total number of regions whose names will be looked up
449    RegionNamesPlanned {
450        /// total number of regions whose names will be resolved
451        total_regions: u32,
452    },
453    /// one region's name has been resolved for the per-region annotation overlay
454    RegionNameResolved {
455        /// the index of this region (0-based)
456        index: u32,
457        /// the total number of regions whose names will be resolved
458        total: u32,
459    },
460}
461
462/// a best-effort progress reporter; emits an event on a `tokio::sync::mpsc`
463/// channel, ignoring the error if the channel is closed or full so that
464/// progress reporting never aborts a render
465fn emit_progress(
466    progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
467    event: MapProgressEvent,
468) {
469    if let Some(sender) = progress {
470        // best-effort: closed or full channel must not abort the render
471        drop(sender.try_send(event));
472    }
473}
474
475/// represents a map tile fetched from the server
476#[derive(Debug, Clone)]
477pub struct MapTile {
478    /// describes the map tile by lower left corner and zoom level
479    descriptor: MapTileDescriptor,
480
481    /// the actual image data
482    image: image::DynamicImage,
483}
484
485impl MapTile {
486    /// the descriptor of the map tile
487    #[must_use]
488    pub const fn descriptor(&self) -> &MapTileDescriptor {
489        &self.descriptor
490    }
491}
492
493impl GridRectangleLike for MapTile {
494    fn grid_rectangle(&self) -> GridRectangle {
495        self.descriptor.grid_rectangle()
496    }
497}
498
499impl image::GenericImageView for MapTile {
500    type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
501
502    fn dimensions(&self) -> (u32, u32) {
503        self.image.dimensions()
504    }
505
506    fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
507        self.image.get_pixel(x, y)
508    }
509}
510
511impl image::GenericImage for MapTile {
512    fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
513        #[expect(
514            deprecated,
515            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
516        )]
517        self.image.get_pixel_mut(x, y)
518    }
519
520    fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
521        self.image.put_pixel(x, y, pixel);
522    }
523
524    fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
525        #[expect(
526            deprecated,
527            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
528        )]
529        self.image.blend_pixel(x, y, pixel);
530    }
531}
532
533impl MapLike for MapTile {
534    fn zoom_level(&self) -> ZoomLevel {
535        self.descriptor.zoom_level().to_owned()
536    }
537
538    fn image(&self) -> &image::DynamicImage {
539        &self.image
540    }
541
542    fn image_mut(&mut self) -> &mut image::DynamicImage {
543        &mut self.image
544    }
545}
546
547/// errors that can happen while fetching a map tile from the cache
548#[derive(Debug, thiserror::Error)]
549pub enum MapTileCacheError {
550    /// error manipulating files in the cache directory
551    #[error("error manipulating files in the cache directory: {0}")]
552    CacheDirectoryFileError(std::io::Error),
553    /// reqwest error when fetching the map tile from the server
554    #[error("reqwest error when fetching the map tile from the server: {0}")]
555    ReqwestError(#[from] reqwest::Error),
556    /// HTTP request is not success
557    #[error("HTTP request is not success: URL {0} response status {1} headers {2:#?} body {3}")]
558    HttpError(
559        String,
560        reqwest::StatusCode,
561        reqwest::header::HeaderMap,
562        String,
563    ),
564    /// failed to clone request for cache policy use (which should not happen
565    /// unless the body is a stream which it is not for us)
566    #[error("failed to clone request for cache policy")]
567    FailedToCloneRequest,
568    /// the ratelimiter returned a non-retriable error (e.g. the requested
569    /// token count exceeds its capacity)
570    #[error("ratelimiter rejected request: {0:?}")]
571    RatelimiterError(ratelimit::TryWaitError),
572    /// error guessing image format
573    #[error("error guessing image format: {0}")]
574    ImageFormatGuessError(std::io::Error),
575    /// error reading the raw map tile into an image
576    #[error("error reading the raw map tile into an image: {0}")]
577    ImageError(#[from] image::ImageError),
578    /// error decoding the JSON serialized CachePolicy
579    #[error("error decoding the JSON serialized CachePolicy: {0}")]
580    CachePolicyJsonDecodeError(#[from] serde_json::Error),
581    /// error creating a zoom level
582    #[error("error creating a zoom level: {0}")]
583    ZoomLevelError(#[from] ZoomLevelError),
584    /// error when trying to load cache policy that we previously checked
585    /// existed on disk
586    #[error("error when trying to load cache policy that we previously checked existed on disk")]
587    CachePolicyError,
588}
589
590/// a cache for map tiles on the local filesystem
591#[derive(derive_more::Debug)]
592pub struct MapTileCache {
593    /// the client used to make HTTP requests for map tiles not in the local cache
594    client: reqwest::Client,
595    /// the rate limiter for map tile requests to the server
596    #[debug(skip)]
597    ratelimiter: Option<ratelimit::Ratelimiter>,
598    /// the cache directory
599    cache_directory: PathBuf,
600    /// the in-memory cache
601    #[debug(skip)]
602    cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
603}
604
605/// status of a cache entry on disk
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub enum MapTileCacheEntryStatus {
608    /// no files at all related to a map tile in the cache
609    Missing,
610    /// an incomplete set of files related to a map tile in the cache
611    Invalid,
612    /// a usable set of files related to a map tile in the cache (cache policy + either a map tile or an absence marker)
613    Valid,
614}
615
616/// a wrapper around response to force status from 403 to 404 for absent map
617/// tiles so `http_cache_semantics::CachePolicy` becomes usable on those responses
618#[derive(Debug)]
619pub struct MapTileNegativeResponse(reqwest::Response);
620
621impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
622    fn status(&self) -> http::status::StatusCode {
623        match self.0.status() {
624            http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
625            status => status,
626        }
627    }
628
629    fn headers(&self) -> &http::header::HeaderMap {
630        self.0.headers()
631    }
632}
633
634impl MapTileCache {
635    /// creates a new `MapTileCache`
636    #[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
637    #[must_use]
638    pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
639        #[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
640        let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
641        Self {
642            client: reqwest::Client::new(),
643            ratelimiter,
644            cache_directory,
645            cache,
646        }
647    }
648
649    /// the file name of a map tile cache file
650    #[must_use]
651    fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
652        format!(
653            "map-{}-{}-{}-objects.jpg",
654            map_tile_descriptor.zoom_level(),
655            map_tile_descriptor.lower_left_corner().x(),
656            map_tile_descriptor.lower_left_corner().y(),
657        )
658    }
659
660    /// the file name of a map tile in the cache directory
661    #[must_use]
662    fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
663        self.cache_directory
664            .join(Self::map_tile_file_name(map_tile_descriptor))
665    }
666
667    /// the file name marking a negative response in the cache directory
668    #[must_use]
669    fn map_tile_cache_negative_response_file_name(
670        &self,
671        map_tile_descriptor: &MapTileDescriptor,
672    ) -> PathBuf {
673        self.cache_directory.join(format!(
674            "{}.does-not-exist",
675            Self::map_tile_file_name(map_tile_descriptor)
676        ))
677    }
678
679    /// the file name of the cache policy file in the cache directory
680    #[must_use]
681    fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
682        self.cache_directory.join(format!(
683            "{}.cache-policy.json",
684            Self::map_tile_file_name(map_tile_descriptor)
685        ))
686    }
687
688    /// the URL of a map tile on the Second Life main map server
689    #[must_use]
690    fn map_tile_url(map_tile_descriptor: &MapTileDescriptor) -> String {
691        format!(
692            "https://secondlife-maps-cdn.akamaized.net/{}",
693            Self::map_tile_file_name(map_tile_descriptor),
694        )
695    }
696
697    /// check if a cache entry is missing, invalid or valid (either cache policy + map tile or cache policy + negative response)
698    async fn cache_entry_status(
699        &self,
700        map_tile_descriptor: &MapTileDescriptor,
701    ) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
702        match (
703            self.cache_policy_file_name(map_tile_descriptor).exists(),
704            self.map_tile_cache_file_name(map_tile_descriptor).exists(),
705            self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
706                .exists(),
707        ) {
708            (false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
709            (true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
710            (cp, tile, neg) => {
711                tracing::warn!(
712                    "cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
713                    cp,
714                    tile,
715                    neg
716                );
717                Ok(MapTileCacheEntryStatus::Invalid)
718            }
719        }
720    }
721
722    /// loads the cached `MapTile` and cache policy from the cache directory
723    /// or from the in-memory LRU cache
724    ///
725    /// # Errors
726    ///
727    /// returns an error if file operations fail
728    async fn fetch_cached_map_tile(
729        &mut self,
730        map_tile_descriptor: &MapTileDescriptor,
731    ) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
732    {
733        if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
734            return Ok(Some(cache_entry.to_owned()));
735        }
736        let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
737        let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
738        if cache_entry_status == MapTileCacheEntryStatus::Invalid {
739            self.remove_cached_tile(map_tile_descriptor).await?;
740            return Ok(None);
741        }
742        if cache_entry_status == MapTileCacheEntryStatus::Missing {
743            return Ok(None);
744        }
745        let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
746            return Err(MapTileCacheError::CachePolicyError);
747        };
748        if cache_file.exists() {
749            let cached_map_tile = image::ImageReader::open(cache_file)
750                .map_err(MapTileCacheError::CacheDirectoryFileError)?
751                .decode()?;
752            Ok(Some((
753                Some(MapTile {
754                    descriptor: map_tile_descriptor.to_owned(),
755                    image: cached_map_tile,
756                }),
757                cache_policy,
758            )))
759        } else {
760            // since we know the cache entry status is valid and no map tile exists we must be dealing with a cached absence
761            Ok(Some((None, cache_policy)))
762        }
763    }
764
765    /// clears the data about a specific map tile from the cache
766    async fn remove_cached_tile(
767        &mut self,
768        map_tile_descriptor: &MapTileDescriptor,
769    ) -> Result<(), MapTileCacheError> {
770        tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
771        self.cache.pop(map_tile_descriptor);
772        let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
773        let cache_file_negative_response =
774            self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
775        let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
776        if cache_file.exists() {
777            std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
778        }
779        if cache_file_negative_response.exists() {
780            std::fs::remove_file(cache_file_negative_response)
781                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
782        }
783        if cache_policy_file.exists() {
784            std::fs::remove_file(cache_policy_file)
785                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
786        }
787        Ok(())
788    }
789
790    /// loads the `http_cache_semantics::CachePolicy` for a cached map tile
791    /// or absence from disk cache
792    ///
793    /// # Errors
794    ///
795    /// returns an error if file operations or JSON deserialization fail
796    async fn load_cache_policy(
797        &self,
798        map_tile_descriptor: &MapTileDescriptor,
799    ) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
800        let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
801        if !cache_policy_file.exists() {
802            return Ok(None);
803        }
804        let cache_policy = std::fs::read_to_string(cache_policy_file)
805            .map_err(MapTileCacheError::CacheDirectoryFileError)?;
806        Ok(serde_json::from_str(&cache_policy)?)
807    }
808
809    /// stores the cache policy in the disk cache
810    ///
811    /// # Errors
812    ///
813    /// returns an error if there was an error in the file operation or when
814    /// serializing the cache policy
815    async fn store_cache_policy(
816        &self,
817        map_tile_descriptor: &MapTileDescriptor,
818        cache_policy: http_cache_semantics::CachePolicy,
819    ) -> Result<(), MapTileCacheError> {
820        if !self.cache_directory.exists() {
821            std::fs::create_dir_all(&self.cache_directory)
822                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
823        }
824        let cache_policy = serde_json::to_string(&cache_policy)?;
825        std::fs::write(
826            self.cache_policy_file_name(map_tile_descriptor),
827            cache_policy,
828        )
829        .map_err(MapTileCacheError::CacheDirectoryFileError)?;
830        Ok(())
831    }
832
833    /// marks a tile as missing in the cache if the cache policy indicates
834    /// it is storable
835    ///
836    /// # Errors
837    ///
838    /// returns an error if there was an error in the file operations
839    /// or serialization of the cache policy
840    async fn cache_missing_tile(
841        &mut self,
842        map_tile_descriptor: &MapTileDescriptor,
843        cache_policy: http_cache_semantics::CachePolicy,
844    ) -> Result<(), MapTileCacheError> {
845        if cache_policy.is_storable() {
846            tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
847            self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
848                .await?;
849            let cache_file_negative_response =
850                self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
851            std::fs::File::create(cache_file_negative_response)
852                .map_err(MapTileCacheError::CacheDirectoryFileError)?;
853            self.cache
854                .put(map_tile_descriptor.clone(), (None, cache_policy));
855        } else {
856            tracing::warn!(
857                "Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
858            );
859        }
860        Ok(())
861    }
862
863    /// stores a tile in the cache if the cache policy indicates that
864    /// it is storable
865    ///
866    /// # Errors
867    ///
868    /// returns an error if there was an error in the file operations
869    /// or serialization of the cache policy
870    async fn cache_tile(
871        &mut self,
872        map_tile_descriptor: &MapTileDescriptor,
873        map_tile: &MapTile,
874        cache_policy: http_cache_semantics::CachePolicy,
875    ) -> Result<(), MapTileCacheError> {
876        if cache_policy.is_storable() {
877            tracing::debug!("Caching map tile {map_tile_descriptor:?}");
878            self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
879                .await?;
880            map_tile
881                .image
882                .save(self.map_tile_cache_file_name(map_tile_descriptor))?;
883            self.cache.put(
884                map_tile_descriptor.clone(),
885                (Some(map_tile.to_owned()), cache_policy),
886            );
887        } else {
888            tracing::warn!(
889                "Map tile {map_tile_descriptor:?} not storable according to cache policy"
890            );
891        }
892        Ok(())
893    }
894
895    /// fetches a map tile from the Second Life main map servers
896    /// or the local cache
897    ///
898    /// # Errors
899    ///
900    /// returns an error if the HTTP request fails of if the result fails to be
901    /// parsed as an image
902    pub async fn get_map_tile(
903        &mut self,
904        map_tile_descriptor: &MapTileDescriptor,
905    ) -> Result<Option<MapTile>, MapTileCacheError> {
906        Ok(self.get_map_tile_with_outcome(map_tile_descriptor).await?.0)
907    }
908
909    /// fetches a map tile from the Second Life main map servers
910    /// or the local cache and additionally reports where the tile came from
911    /// (memory cache, disk cache, network, or known-missing) so callers can
912    /// surface progress information
913    ///
914    /// # Errors
915    ///
916    /// returns an error if the HTTP request fails of if the result fails to be
917    /// parsed as an image
918    pub async fn get_map_tile_with_outcome(
919        &mut self,
920        map_tile_descriptor: &MapTileDescriptor,
921    ) -> Result<(Option<MapTile>, TileOutcome), MapTileCacheError> {
922        tracing::debug!("Map tile {map_tile_descriptor:?} requested");
923        let url = Self::map_tile_url(map_tile_descriptor);
924        let request = self.client.get(&url).build()?;
925        let now = std::time::SystemTime::now();
926        // peek without disturbing the LRU order so we can distinguish a
927        // memory-cache hit from a disk-cache hit when classifying the outcome
928        let memory_cache_hit = self.cache.peek(map_tile_descriptor).is_some();
929        if let Some((cached_map_tile, cache_policy)) =
930            self.fetch_cached_map_tile(map_tile_descriptor).await?
931        {
932            if cached_map_tile.is_some() {
933                tracing::debug!("Found matching map tile in cache, checking freshness");
934            } else {
935                tracing::debug!("Found matching map tile absence in cache, checking freshness");
936            }
937            if let http_cache_semantics::BeforeRequest::Fresh(_) =
938                cache_policy.before_request(&request, now)
939            {
940                if cached_map_tile.is_some() {
941                    tracing::debug!("Using cached map tile");
942                } else {
943                    tracing::debug!("Using cached map tile absence");
944                }
945                let outcome = if cached_map_tile.is_none() {
946                    TileOutcome::Missing
947                } else if memory_cache_hit {
948                    TileOutcome::LoadedFromMemoryCache
949                } else {
950                    TileOutcome::LoadedFromDiskCache
951                };
952                return Ok((cached_map_tile, outcome));
953            }
954            tracing::debug!("Map tile cache not fresh, removing from cache");
955            self.remove_cached_tile(map_tile_descriptor).await?;
956        }
957        tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
958        if let Some(ratelimiter) = &self.ratelimiter {
959            while let Err(err) = ratelimiter.try_wait() {
960                match err {
961                    ratelimit::TryWaitError::Insufficient(duration) => {
962                        tokio::time::sleep(duration).await;
963                    }
964                    _ => {
965                        return Err(MapTileCacheError::RatelimiterError(err));
966                    }
967                }
968            }
969        }
970        tracing::debug!("Fetching map tile from server at {}", url);
971        let response = self
972            .client
973            .execute(
974                request
975                    .try_clone()
976                    .ok_or(MapTileCacheError::FailedToCloneRequest)?,
977            )
978            .await?;
979        tracing::debug!(
980            "Server response received: status {}, headers\n{:#?}",
981            response.status(),
982            response.headers()
983        );
984        if !response.status().is_success() {
985            if response.status() == reqwest::StatusCode::FORBIDDEN {
986                // FORBIDDEN (403) is returned when the file does not exist
987                // which likely means there is no region/map tile
988                tracing::debug!(
989                    "Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
990                );
991                let cache_policy = http_cache_semantics::CachePolicy::new(
992                    &request,
993                    &MapTileNegativeResponse(response),
994                );
995                self.cache_missing_tile(map_tile_descriptor, cache_policy)
996                    .await?;
997                return Ok((None, TileOutcome::Missing));
998            }
999            return Err(MapTileCacheError::HttpError(
1000                url.to_owned(),
1001                response.status(),
1002                response.headers().to_owned(),
1003                response.text().await?,
1004            ));
1005        }
1006        let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
1007        let raw_response_body = response.bytes().await?;
1008        tracing::debug!("Parsing received map tile to image");
1009        let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
1010            .with_guessed_format()
1011            .map_err(MapTileCacheError::ImageFormatGuessError)?
1012            .decode()?;
1013        let map_tile = MapTile {
1014            descriptor: map_tile_descriptor.to_owned(),
1015            image,
1016        };
1017        self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
1018            .await?;
1019        tracing::debug!("Returning freshly fetched map tile");
1020        Ok((Some(map_tile), TileOutcome::FetchedFromNetwork))
1021    }
1022
1023    /// figures out if a map tile exist by checking the local in-memory and
1024    /// disk caches or fetching the map tile from the server
1025    ///
1026    /// # Errors
1027    ///
1028    /// returns an error if fetching the map tile from cache or remotely fails
1029    pub async fn does_map_tile_exist(
1030        &mut self,
1031        map_tile_descriptor: &MapTileDescriptor,
1032    ) -> Result<bool, MapTileCacheError> {
1033        let url = Self::map_tile_url(map_tile_descriptor);
1034        if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
1035            let request = self.client.get(&url).build()?;
1036            let now = std::time::SystemTime::now();
1037            if let http_cache_semantics::BeforeRequest::Fresh(_) =
1038                cache_policy.before_request(&request, now)
1039            {
1040                return Ok(map_tile.is_some());
1041            }
1042        }
1043        if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
1044            && let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
1045        {
1046            let request = self.client.get(&url).build()?;
1047            let now = std::time::SystemTime::now();
1048            if let http_cache_semantics::BeforeRequest::Fresh(_) =
1049                cache_policy.before_request(&request, now)
1050            {
1051                if self
1052                    .map_tile_cache_negative_response_file_name(map_tile_descriptor)
1053                    .exists()
1054                {
1055                    return Ok(false);
1056                }
1057                return Ok(true);
1058            }
1059        }
1060        Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
1061    }
1062
1063    /// figures out if a region exists based on the existence of map tiles for it, starting with the lowest zoom level
1064    /// and potentially going up to the highest one if all the other zoom levels have a tile for that region
1065    ///
1066    /// # Errors
1067    ///
1068    /// returns an error if fetching map tiles from cache or remotely fails
1069    pub async fn does_region_exist(
1070        &mut self,
1071        grid_coordinates: &GridCoordinates,
1072    ) -> Result<bool, MapTileCacheError> {
1073        for zoom_level in (1..=8).rev() {
1074            tracing::debug!(
1075                "Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
1076            );
1077            let map_tile_descriptor = MapTileDescriptor::new(
1078                ZoomLevel::try_new(zoom_level)?,
1079                grid_coordinates.to_owned(),
1080            );
1081            if !self.does_map_tile_exist(&map_tile_descriptor).await? {
1082                tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
1083                return Ok(false);
1084            }
1085            let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
1086            if cache_entry_status == MapTileCacheEntryStatus::Valid {}
1087        }
1088        tracing::debug!(
1089            "Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
1090        );
1091        Ok(true)
1092    }
1093}
1094
1095/// represents a map assembled from map tiles
1096#[derive(Debug, Clone)]
1097pub struct Map {
1098    /// the zoom level of this map
1099    zoom_level: ZoomLevel,
1100    /// the grid rectangle of regions represented by this map
1101    grid_rectangle: GridRectangle,
1102    /// the actual map image
1103    image: image::DynamicImage,
1104}
1105
1106/// represents errors that can occur while creating a map
1107#[derive(Debug, thiserror::Error)]
1108pub enum MapError {
1109    /// an error in the map tile cache
1110    #[error("error in map tile cache while assembling map: {0}")]
1111    MapTileCacheError(#[from] MapTileCacheError),
1112    /// an error occurred when trying to calculate the zoom level that fits the
1113    /// map grid rectangle into the output image
1114    #[error(
1115        "error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
1116    )]
1117    ZoomFitError(#[from] ZoomFitError),
1118    /// failed to crop a map tile to the required size
1119    #[error("error when cropping a map tile to the required size")]
1120    MapTileCropError,
1121    /// failed to calculate pixel coordinates where we want to place a map tile crop
1122    #[error("error when calculating pixel coordinates where we want to place a map tile crop")]
1123    MapCoordinateError,
1124    /// no overlap between map tile we fetched and output map (should not happen)
1125    #[error("no overlap between map tile we fetched and output map (should not happen)")]
1126    NoOverlapError,
1127    /// no grid coordinates were returned for one of the region names in the
1128    /// USB Notecard
1129    #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
1130    NoGridCoordinatesForRegion(RegionName),
1131    /// error in region name to grid coordinate cache
1132    #[error("error in region name to grid coordinate cache: {0}")]
1133    RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
1134    /// error calculating spline
1135    #[error("error calculating spline: {0}")]
1136    SplineError(
1137        #[source]
1138        #[from]
1139        uniform_cubic_splines::SplineError,
1140    ),
1141}
1142
1143impl Map {
1144    /// creates a new `Map`
1145    ///
1146    /// if we choose not to fill the missing map tiles they appear as black
1147    ///
1148    /// if we choose not to fill the missing regions they appear in a color
1149    /// similar to water but filling them in has some performance impact since
1150    /// we need to check if the region exists by fetching higher resolution
1151    /// map tiles for it.
1152    ///
1153    /// # Errors
1154    ///
1155    /// returns an error if fetching the map tiles fails
1156    ///
1157    /// # Arguments
1158    ///
1159    /// * `map_tile_cache` - the map tile cache to use to fetch the map tiles
1160    /// * `x` - the width of the map in pixels
1161    /// * `y` - the height of the map in pixels
1162    /// * `grid_rectangle` - the grid rectangle of regions represented by this map
1163    pub async fn new(
1164        map_tile_cache: &mut MapTileCache,
1165        x: u32,
1166        y: u32,
1167        grid_rectangle: GridRectangle,
1168        fill_missing_map_tiles: Option<image::Rgba<u8>>,
1169        fill_missing_regions: Option<image::Rgba<u8>>,
1170    ) -> Result<Self, MapError> {
1171        Self::new_with_progress(
1172            map_tile_cache,
1173            x,
1174            y,
1175            grid_rectangle,
1176            fill_missing_map_tiles,
1177            fill_missing_regions,
1178            None,
1179        )
1180        .await
1181    }
1182
1183    /// creates a new `Map`, emitting per-tile progress events on the
1184    /// provided channel as it works (closed or full channels are tolerated
1185    /// silently, so the caller can drop the receiver at any time without
1186    /// aborting the render)
1187    ///
1188    /// See [`Self::new`] for the meaning of the other parameters.
1189    ///
1190    /// # Errors
1191    ///
1192    /// returns an error if fetching the map tiles fails
1193    pub async fn new_with_progress(
1194        map_tile_cache: &mut MapTileCache,
1195        x: u32,
1196        y: u32,
1197        grid_rectangle: GridRectangle,
1198        fill_missing_map_tiles: Option<image::Rgba<u8>>,
1199        fill_missing_regions: Option<image::Rgba<u8>>,
1200        progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1201    ) -> Result<Self, MapError> {
1202        let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1203            grid_rectangle.size_x(),
1204            grid_rectangle.size_y(),
1205            x,
1206            y,
1207        )?;
1208        let actual_x = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1209            * <u16 as Into<u32>>::into(grid_rectangle.size_x());
1210        let actual_y = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1211            * <u16 as Into<u32>>::into(grid_rectangle.size_y());
1212        tracing::debug!(
1213            "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})"
1214        );
1215        let x = actual_x;
1216        let y = actual_y;
1217        let image = image::DynamicImage::new_rgb8(x, y);
1218        let mut result = Self {
1219            zoom_level,
1220            grid_rectangle,
1221            image,
1222        };
1223        // count the unique tile descriptors we will process so the caller
1224        // can show a determinate progress indicator
1225        let mut total_tiles: u32 = 0;
1226        for region_x in result.x_range() {
1227            for region_y in result.y_range() {
1228                let grid_coordinates = GridCoordinates::new(region_x, region_y);
1229                let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1230                let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1231                    return Err(MapError::NoOverlapError);
1232                };
1233                if overlap.lower_left_corner().x() == region_x
1234                    && overlap.lower_left_corner().y() == region_y
1235                {
1236                    total_tiles = total_tiles.saturating_add(1);
1237                }
1238            }
1239        }
1240        emit_progress(
1241            progress,
1242            MapProgressEvent::PlanComputed {
1243                zoom_level,
1244                total_tiles,
1245            },
1246        );
1247        if fill_missing_regions.is_some() {
1248            // upper bound: every region in the requested rectangle. The
1249            // actual count can be lower if some primary tiles turn out to
1250            // be missing (those regions get the missing-tile colour instead
1251            // of an individual check).
1252            let total_regions: u32 =
1253                u32::from(result.size_x()).saturating_mul(u32::from(result.size_y()));
1254            emit_progress(
1255                progress,
1256                MapProgressEvent::RegionCheckPlanned { total_regions },
1257            );
1258        }
1259        for region_x in result.x_range() {
1260            for region_y in result.y_range() {
1261                let grid_coordinates = GridCoordinates::new(region_x, region_y);
1262                let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1263                let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1264                    return Err(MapError::NoOverlapError);
1265                };
1266                if overlap.lower_left_corner().x() != region_x
1267                    || overlap.lower_left_corner().y() != region_y
1268                {
1269                    // we should have already processed this map tile when
1270                    // we encountered the lower left corner of the overlap
1271                    continue;
1272                }
1273                tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1274                emit_progress(
1275                    progress,
1276                    MapProgressEvent::TileStarted {
1277                        descriptor: map_tile_descriptor.to_owned(),
1278                    },
1279                );
1280                let (fetched_tile, outcome) = map_tile_cache
1281                    .get_map_tile_with_outcome(&map_tile_descriptor)
1282                    .await?;
1283                emit_progress(
1284                    progress,
1285                    MapProgressEvent::TileFinished {
1286                        descriptor: map_tile_descriptor.to_owned(),
1287                        outcome,
1288                    },
1289                );
1290                if let Some(map_tile) = fetched_tile {
1291                    let crop = map_tile
1292                        .crop_imm_grid_rectangle(&overlap)
1293                        .ok_or(MapError::MapTileCropError)?;
1294                    tracing::debug!(
1295                        "Cropped map tile to ({}, {})+{}x{}",
1296                        crop.offsets().0,
1297                        crop.offsets().1,
1298                        (*crop).dimensions().0,
1299                        (*crop).dimensions().1
1300                    );
1301                    // we need to use y = 256 here since the crop is inserted by pixel coordinates which means
1302                    // we need the upper left corner, not the lower left one of the region as an origin
1303                    let (replace_x, replace_y) = result
1304                        .pixel_coordinates_for_coordinates(
1305                            &overlap.upper_left_corner(),
1306                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1307                        )
1308                        .ok_or(MapError::MapCoordinateError)?;
1309                    tracing::debug!(
1310                        "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1311                    );
1312                    image::imageops::replace(
1313                        &mut result,
1314                        &*crop,
1315                        replace_x.into(),
1316                        replace_y.into(),
1317                    );
1318                    if let Some(fill_color) = fill_missing_regions {
1319                        for overlap_region_x in overlap.x_range() {
1320                            for overlap_region_y in overlap.y_range() {
1321                                let grid_coordinates =
1322                                    GridCoordinates::new(overlap_region_x, overlap_region_y);
1323                                let exists =
1324                                    map_tile_cache.does_region_exist(&grid_coordinates).await?;
1325                                emit_progress(
1326                                    progress,
1327                                    MapProgressEvent::RegionChecked {
1328                                        x: overlap_region_x,
1329                                        y: overlap_region_y,
1330                                        exists,
1331                                    },
1332                                );
1333                                if !exists {
1334                                    let pixel_min = result.pixel_coordinates_for_coordinates(
1335                                        &grid_coordinates,
1336                                        &RegionCoordinates::new(0f32, 256f32, 0f32),
1337                                    );
1338                                    let pixel_max = result.pixel_coordinates_for_coordinates(
1339                                        &grid_coordinates,
1340                                        &RegionCoordinates::new(256f32, 0f32, 0f32),
1341                                    );
1342                                    if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1343                                        (pixel_min, pixel_max)
1344                                    {
1345                                        for x in min_x..max_x {
1346                                            for y in min_y..max_y {
1347                                                <Self as image::GenericImage>::put_pixel(
1348                                                    &mut result,
1349                                                    x,
1350                                                    y,
1351                                                    fill_color,
1352                                                );
1353                                            }
1354                                        }
1355                                    }
1356                                }
1357                            }
1358                        }
1359                    }
1360                } else if let Some(fill_color) = fill_missing_map_tiles {
1361                    let (replace_x, replace_y) = result
1362                        .pixel_coordinates_for_coordinates(
1363                            &overlap.upper_left_corner(),
1364                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1365                        )
1366                        .ok_or(MapError::MapCoordinateError)?;
1367                    let pixel_size_x =
1368                        u32::from(overlap.size_x()) * u32::from(zoom_level.pixels_per_region());
1369                    let pixel_size_y =
1370                        u32::from(overlap.size_y()) * u32::from(zoom_level.pixels_per_region());
1371                    for x in replace_x..replace_x + pixel_size_x {
1372                        for y in replace_y..replace_y + pixel_size_y {
1373                            <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1374                        }
1375                    }
1376                }
1377            }
1378        }
1379        Ok(result)
1380    }
1381
1382    /// draws a route from a `USBNotecard` onto the map
1383    ///
1384    /// # Errors
1385    ///
1386    /// fails if the region name to grid coordinate conversion fails
1387    /// or the conversion of those into pixel coordinates
1388    pub async fn draw_route(
1389        &mut self,
1390        region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1391        usb_notecard: &USBNotecard,
1392        color: image::Rgba<u8>,
1393    ) -> Result<(), MapError> {
1394        self.draw_route_with_progress(
1395            region_name_to_grid_coordinates_cache,
1396            usb_notecard,
1397            color,
1398            None,
1399        )
1400        .await
1401    }
1402
1403    /// draws a route from a `USBNotecard` onto the map, emitting progress
1404    /// events for each waypoint resolved on the provided channel
1405    ///
1406    /// See [`Self::draw_route`] for the meaning of the other parameters.
1407    ///
1408    /// # Errors
1409    ///
1410    /// fails if the region name to grid coordinate conversion fails
1411    /// or the conversion of those into pixel coordinates
1412    pub async fn draw_route_with_progress(
1413        &mut self,
1414        region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1415        usb_notecard: &USBNotecard,
1416        color: image::Rgba<u8>,
1417        progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1418    ) -> Result<(), MapError> {
1419        tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1420        let waypoints = usb_notecard.waypoints();
1421        let total_waypoints = waypoints.len();
1422        emit_progress(progress, MapProgressEvent::RoutePlanned { total_waypoints });
1423        let mut pixel_waypoints = Vec::new();
1424        for (index, waypoint) in waypoints.iter().enumerate() {
1425            let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1426                .get_grid_coordinates(waypoint.location().region_name())
1427                .await?
1428            else {
1429                return Err(MapError::NoGridCoordinatesForRegion(
1430                    waypoint.location().region_name().to_owned(),
1431                ));
1432            };
1433            emit_progress(
1434                progress,
1435                MapProgressEvent::RouteWaypointResolved {
1436                    index,
1437                    total: total_waypoints,
1438                    region: waypoint.location().region_name().to_owned(),
1439                },
1440            );
1441            let (x, y) = self
1442                .pixel_coordinates_for_coordinates(
1443                    &grid_coordinates,
1444                    &waypoint.region_coordinates(),
1445                )
1446                .ok_or(MapError::MapCoordinateError)?;
1447            tracing::debug!(
1448                "Drawing waypoint at ({x}, {y}) for location {:?}",
1449                waypoint.location()
1450            );
1451            //self.draw_waypoint(x, y, color);
1452            #[expect(
1453                clippy::cast_precision_loss,
1454                reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1455            )]
1456            pixel_waypoints.push((x as f32, y as f32));
1457        }
1458        self.draw_pixel_waypoint_route(&pixel_waypoints, color)?;
1459        Ok(())
1460    }
1461
1462    /// draws a route through the given already-resolved pixel coordinates
1463    ///
1464    /// This is the pure geometry/rasterization half of
1465    /// [`Self::draw_route_with_progress`], split out so it can be unit tested
1466    /// without any network access (it only touches the image and the spline
1467    /// crate).
1468    ///
1469    /// It is public so callers that already hold resolved grid coordinates (for
1470    /// example to compute overlay occupancy via [`crate::coverage`]) can draw a
1471    /// route onto a [`Self::blank`] map without any network access, by first
1472    /// converting their coordinates with
1473    /// [`MapLike::pixel_coordinates_for_coordinates`].
1474    ///
1475    /// # Errors
1476    ///
1477    /// returns an error if the spline crate returns an error
1478    pub fn draw_pixel_waypoint_route(
1479        &mut self,
1480        pixel_waypoints: &[(f32, f32)],
1481        color: image::Rgba<u8>,
1482    ) -> Result<(), uniform_cubic_splines::SplineError> {
1483        let waypoint_count = pixel_waypoints.len();
1484        let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1485            // no route if there are no waypoints
1486            return Ok(());
1487        };
1488        let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1489        else {
1490            // no route if there is only one waypoint
1491            return Ok(());
1492        };
1493        let extra_before_start = (
1494            first.0 - (second.0 - first.0),
1495            first.1 - (second.1 - first.1),
1496        );
1497        let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1498            // no route if there are no waypoints (but this should never happen since we already returned at the first split_first() above)
1499            return Ok(());
1500        };
1501        let Some((second_to_last, _pixel_waypoints_rest)) =
1502            pixel_waypoints_all_but_last.split_last()
1503        else {
1504            // no route if there is only one waypoint (but this should never happen since we already returned at the second split_first() above)
1505            return Ok(());
1506        };
1507        let extra_after_end = (
1508            last.0 + (last.0 - second_to_last.0),
1509            last.1 + (last.1 - second_to_last.1),
1510        );
1511        let mut knots = vec![extra_before_start];
1512        knots.extend(pixel_waypoints.to_owned());
1513        knots.push(extra_after_end);
1514        let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1515        let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1516            let point_x =
1517                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1518                    v, &points_x,
1519                )?;
1520            let point_y =
1521                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1522                    v, &points_y,
1523                )?;
1524            Ok((point_x, point_y))
1525        };
1526        // For the common case (>= 3 waypoints) the parameter that lands on
1527        // waypoint `i` keeps its historical value `i / (waypoint_count - 2)` so
1528        // route rendering is unchanged. For exactly 2 waypoints the old
1529        // denominator was 0 (NaN/inf), so use the mathematically correct uniform
1530        // mapping `i / (waypoint_count - 1)` (= `i` for n == 2), which places
1531        // waypoint 0 at x = 0 and waypoint 1 at x = 1.
1532        #[expect(
1533            clippy::cast_precision_loss,
1534            reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1535        )]
1536        let waypoint_parameter_denominator = if waypoint_count <= 2 {
1537            (waypoint_count as f32 - 1f32).max(1f32)
1538        } else {
1539            waypoint_count as f32 - 2f32
1540        };
1541        let spline_value_for_waypoint = |i: usize| -> f32 {
1542            #[expect(
1543                clippy::cast_precision_loss,
1544                reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1545            )]
1546            let i = i as f32;
1547            i / waypoint_parameter_denominator
1548        };
1549        let spline_value_between_waypoints = spline_value_for_waypoint(1);
1550        let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1551            ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1552        };
1553        let mut last_point: Option<(f32, f32)> = None;
1554        // For >= 3 waypoints we iterate over all but the last waypoint (the
1555        // historical behaviour). For exactly 2 waypoints we must also reach the
1556        // second waypoint (i == 1) so that `last_point` is `Some` and the curve
1557        // between the two waypoints is actually drawn (otherwise nothing renders).
1558        let outer_loop_count = if waypoint_count <= 2 {
1559            waypoint_count
1560        } else {
1561            waypoint_count - 1
1562        };
1563        for (i, waypoint) in pixel_waypoints.iter().enumerate().take(outer_loop_count) {
1564            /// size of rectangles to use to draw the spline, should be odd
1565            /// or it won't be centered properly
1566            const SPLINE_RECT_SIZE: u8 = 3;
1567            tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1568            let v = spline_value_for_waypoint(i);
1569            let point = sample(v)?;
1570            tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1571            if let Some(last_point) = last_point {
1572                let distance_from_last_point = distance_between_points(point, last_point);
1573                tracing::debug!(
1574                    "Waypoint {i} is {:?} from last waypoint",
1575                    distance_from_last_point
1576                );
1577                #[expect(
1578                    clippy::cast_possible_truncation,
1579                    reason = "we want an integer count for the number of samples"
1580                )]
1581                #[expect(
1582                    clippy::cast_sign_loss,
1583                    reason = "we want a positive count for the number of samples"
1584                )]
1585                let samples_between_last_waypoint_and_this_one =
1586                    (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1587                // The historical step uses `samples - 2` as the denominator so
1588                // that at j = samples - 1 the factor slightly exceeds 1 and the
1589                // drawing "overshoots" one sample past the previous waypoint for
1590                // gap-free coverage. That denominator is 0 when samples == 2
1591                // (NaN parameter -> stray rects at the clamped (0,0) corner), so
1592                // floor it to 1 in that single case. For samples >= 3 the value
1593                // is unchanged; samples <= 1 never enters this loop.
1594                #[expect(
1595                    clippy::cast_precision_loss,
1596                    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"
1597                )]
1598                let sample_step_denominator =
1599                    (samples_between_last_waypoint_and_this_one as f32 - 2f32).max(1f32);
1600                for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1601                    #[expect(
1602                        clippy::cast_precision_loss,
1603                        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"
1604                    )]
1605                    let v =
1606                        v - spline_value_between_waypoints * (j as f32 / sample_step_denominator);
1607                    let sample_point = sample(v)?;
1608                    #[expect(
1609                        clippy::cast_possible_truncation,
1610                        reason = "we want integer pixel coordinates for use in the image library"
1611                    )]
1612                    imageproc::drawing::draw_filled_rect_mut(
1613                        self.image_mut(),
1614                        imageproc::rect::Rect::at(
1615                            sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1616                            sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1617                        )
1618                        .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1619                        color,
1620                    );
1621                }
1622                self.draw_arrow(
1623                    sample(v - (0.1f32 * spline_value_between_waypoints))?,
1624                    point,
1625                    color,
1626                );
1627            }
1628            last_point = Some(point);
1629        }
1630        Ok(())
1631    }
1632
1633    /// creates a blank `Map` with a fully transparent RGBA image sized for the
1634    /// given grid rectangle at the given zoom level, without any network access.
1635    ///
1636    /// Unlike [`Self::new`] (which builds an RGB8 base map from fetched tiles)
1637    /// this uses an RGBA8 image so untouched pixels have alpha 0. That is what
1638    /// the [`crate::coverage`] occupancy analysis relies on to tell pixels that
1639    /// were drawn on (route, GLW shapes/labels) apart from blank ones. The pixel
1640    /// geometry (dimensions and
1641    /// [`MapLike::pixel_coordinates_for_coordinates`]) depends only on the zoom
1642    /// level and grid rectangle, not the channel count, so drawing overlays onto
1643    /// this blank map yields exactly the same pixel positions as the real render.
1644    #[must_use]
1645    pub fn blank(grid_rectangle: GridRectangle, zoom_level: ZoomLevel) -> Self {
1646        let width = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1647            * <u16 as Into<u32>>::into(grid_rectangle.size_x());
1648        let height = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1649            * <u16 as Into<u32>>::into(grid_rectangle.size_y());
1650        Self {
1651            zoom_level,
1652            grid_rectangle,
1653            image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1654        }
1655    }
1656
1657    /// creates a blank `Map` sized exactly as [`Self::new`] would size it for the
1658    /// given maximum output dimensions, without any network access.
1659    ///
1660    /// This reproduces the zoom-fit logic of [`Self::new_with_progress`] so the
1661    /// resulting blank map has the identical pixel dimensions the real render
1662    /// would have for the same `grid_rectangle` and the same caps. See
1663    /// [`Self::blank`] for why the image is RGBA8.
1664    ///
1665    /// # Errors
1666    ///
1667    /// returns an error if the zoom level that fits the rectangle into the
1668    /// output image cannot be calculated
1669    #[expect(
1670        clippy::result_large_err,
1671        reason = "returns the same large MapError as the other Map constructors for a consistent error type at call sites; the only failure here is the zoom-fit calculation"
1672    )]
1673    pub fn blank_fit(
1674        grid_rectangle: GridRectangle,
1675        max_width: u32,
1676        max_height: u32,
1677    ) -> Result<Self, MapError> {
1678        let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1679            grid_rectangle.size_x(),
1680            grid_rectangle.size_y(),
1681            max_width,
1682            max_height,
1683        )?;
1684        Ok(Self::blank(grid_rectangle, zoom_level))
1685    }
1686
1687    /// creates a blank `Map` with a transparent image of the given size for
1688    /// use in tests that exercise the pure drawing geometry without any network
1689    /// access
1690    #[cfg(test)]
1691    fn new_blank_for_test(width: u32, height: u32) -> Self {
1692        #[expect(
1693            clippy::expect_used,
1694            reason = "zoom level 1 is a compile-time constant within the valid range"
1695        )]
1696        let zoom_level = ZoomLevel::try_new(1).expect("zoom level 1 is within the valid range");
1697        Self {
1698            zoom_level,
1699            grid_rectangle: GridRectangle::new(
1700                GridCoordinates::new(0, 0),
1701                GridCoordinates::new(0, 0),
1702            ),
1703            image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1704        }
1705    }
1706
1707    /// saves the map to the specified path
1708    ///
1709    /// # Errors
1710    ///
1711    /// returns an error when the image libraries returns an error
1712    /// when saving the image
1713    pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1714        self.image.save(path)
1715    }
1716}
1717
1718impl GridRectangleLike for Map {
1719    fn grid_rectangle(&self) -> GridRectangle {
1720        self.grid_rectangle.to_owned()
1721    }
1722}
1723
1724impl image::GenericImageView for Map {
1725    type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1726
1727    fn dimensions(&self) -> (u32, u32) {
1728        self.image.dimensions()
1729    }
1730
1731    fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1732        self.image.get_pixel(x, y)
1733    }
1734}
1735
1736impl image::GenericImage for Map {
1737    fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1738        #[expect(
1739            deprecated,
1740            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1741        )]
1742        self.image.get_pixel_mut(x, y)
1743    }
1744
1745    fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1746        self.image.put_pixel(x, y, pixel);
1747    }
1748
1749    fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1750        #[expect(
1751            deprecated,
1752            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1753        )]
1754        self.image.blend_pixel(x, y, pixel);
1755    }
1756}
1757
1758impl MapLike for Map {
1759    fn zoom_level(&self) -> ZoomLevel {
1760        self.zoom_level
1761    }
1762
1763    fn image(&self) -> &image::DynamicImage {
1764        &self.image
1765    }
1766
1767    fn image_mut(&mut self) -> &mut image::DynamicImage {
1768        &mut self.image
1769    }
1770}
1771
1772#[cfg(test)]
1773mod test {
1774    use image::GenericImageView as _;
1775    use tracing_test::traced_test;
1776
1777    use super::*;
1778
1779    #[tokio::test]
1780    async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1781        let temp_dir = tempfile::tempdir()?;
1782        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1783        map_tile_cache
1784            .get_map_tile(&MapTileDescriptor::new(
1785                ZoomLevel::try_new(1)?,
1786                GridCoordinates::new(1136, 1075),
1787            ))
1788            .await?;
1789        Ok(())
1790    }
1791
1792    #[tokio::test]
1793    async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1794        let temp_dir = tempfile::tempdir()?;
1795        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1796        map_tile_cache
1797            .get_map_tile(&MapTileDescriptor::new(
1798                ZoomLevel::try_new(1)?,
1799                GridCoordinates::new(1136, 1075),
1800            ))
1801            .await?;
1802        map_tile_cache
1803            .get_map_tile(&MapTileDescriptor::new(
1804                ZoomLevel::try_new(1)?,
1805                GridCoordinates::new(1136, 1075),
1806            ))
1807            .await?;
1808        Ok(())
1809    }
1810
1811    #[tokio::test]
1812    async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1813        let temp_dir = tempfile::tempdir()?;
1814        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1815        map_tile_cache
1816            .get_map_tile(&MapTileDescriptor::new(
1817                ZoomLevel::try_new(8)?,
1818                GridCoordinates::new(1136, 1075),
1819            ))
1820            .await?;
1821        Ok(())
1822    }
1823
1824    #[traced_test]
1825    #[tokio::test]
1826    async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1827        let temp_dir = tempfile::tempdir()?;
1828        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1829        let mut map_tile_cache =
1830            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1831        let map = Map::new(
1832            &mut map_tile_cache,
1833            512,
1834            512,
1835            GridRectangle::new(
1836                GridCoordinates::new(1135, 1070),
1837                GridCoordinates::new(1136, 1071),
1838            ),
1839            None,
1840            None,
1841        )
1842        .await?;
1843        map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1844        Ok(())
1845    }
1846
1847    #[traced_test]
1848    #[tokio::test]
1849    async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1850        let temp_dir = tempfile::tempdir()?;
1851        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1852        let mut map_tile_cache =
1853            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1854        let map = Map::new(
1855            &mut map_tile_cache,
1856            256,
1857            256,
1858            GridRectangle::new(
1859                GridCoordinates::new(1136, 1074),
1860                GridCoordinates::new(1137, 1075),
1861            ),
1862            None,
1863            None,
1864        )
1865        .await?;
1866        map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1867        Ok(())
1868    }
1869
1870    #[traced_test]
1871    #[tokio::test]
1872    async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1873        let temp_dir = tempfile::tempdir()?;
1874        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1875        let mut map_tile_cache =
1876            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1877        let map = Map::new(
1878            &mut map_tile_cache,
1879            128,
1880            128,
1881            GridRectangle::new(
1882                GridCoordinates::new(1136, 1074),
1883                GridCoordinates::new(1137, 1075),
1884            ),
1885            None,
1886            None,
1887        )
1888        .await?;
1889        map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1890        Ok(())
1891    }
1892
1893    #[traced_test]
1894    #[tokio::test]
1895    async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1896        let temp_dir = tempfile::tempdir()?;
1897        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1898        let mut map_tile_cache =
1899            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1900        let map = Map::new(
1901            &mut map_tile_cache,
1902            2048,
1903            2048,
1904            GridRectangle::new(
1905                GridCoordinates::new(1131, 1068),
1906                GridCoordinates::new(1139, 1075),
1907            ),
1908            None,
1909            None,
1910        )
1911        .await?;
1912        map.save(std::path::Path::new(
1913            "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1914        ))?;
1915        Ok(())
1916    }
1917
1918    #[traced_test]
1919    #[tokio::test]
1920    #[expect(clippy::panic, reason = "panic in test is intentional")]
1921    async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1922    -> Result<(), Box<dyn std::error::Error>> {
1923        let temp_dir = tempfile::tempdir()?;
1924        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1925        let Some(map_tile) = map_tile_cache
1926            .get_map_tile(&MapTileDescriptor::new(
1927                ZoomLevel::try_new(1)?,
1928                GridCoordinates::new(1136, 1075),
1929            ))
1930            .await?
1931        else {
1932            panic!("Expected there to be a region at this location");
1933        };
1934        for in_region_x in 0..=256 {
1935            for in_region_y in 0..=256 {
1936                let grid_coordinates = GridCoordinates::new(1136, 1075);
1937                #[expect(
1938                    clippy::cast_precision_loss,
1939                    reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1940                )]
1941                let region_coordinates =
1942                    RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
1943                tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
1944                assert_eq!(
1945                    map_tile
1946                        .pixel_coordinates_for_coordinates(&grid_coordinates, &region_coordinates,),
1947                    Some((in_region_x, 256 - in_region_y)),
1948                );
1949            }
1950        }
1951        Ok(())
1952    }
1953
1954    #[traced_test]
1955    #[tokio::test]
1956    async fn test_map_pixel_coordinates_for_coordinates_four_regions()
1957    -> Result<(), Box<dyn std::error::Error>> {
1958        let temp_dir = tempfile::tempdir()?;
1959        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1960        let mut map_tile_cache =
1961            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1962        let map = Map::new(
1963            &mut map_tile_cache,
1964            512,
1965            512,
1966            GridRectangle::new(
1967                GridCoordinates::new(1136, 1074),
1968                GridCoordinates::new(1137, 1075),
1969            ),
1970            None,
1971            None,
1972        )
1973        .await?;
1974        for region_offset_x in 0..=1 {
1975            for region_offset_y in 0..=1 {
1976                for in_region_x in 0..=256 {
1977                    for in_region_y in 0..=256 {
1978                        let grid_coordinates =
1979                            GridCoordinates::new(1136 + region_offset_x, 1074 + region_offset_y);
1980                        let region_coordinates = RegionCoordinates::new(
1981                            f32::from(in_region_x),
1982                            f32::from(in_region_y),
1983                            0f32,
1984                        );
1985                        tracing::debug!(
1986                            "Now checking {grid_coordinates:?}, {region_coordinates:?}"
1987                        );
1988                        assert_eq!(
1989                            map.pixel_coordinates_for_coordinates(
1990                                &grid_coordinates,
1991                                &region_coordinates,
1992                            ),
1993                            Some((
1994                                u32::from(region_offset_x * 256 + in_region_x),
1995                                u32::from(512 - (region_offset_y * 256 + in_region_y))
1996                            )),
1997                        );
1998                    }
1999                }
2000            }
2001        }
2002        Ok(())
2003    }
2004
2005    #[traced_test]
2006    #[tokio::test]
2007    #[expect(clippy::panic, reason = "panic in test is intentional")]
2008    async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
2009    -> Result<(), Box<dyn std::error::Error>> {
2010        let temp_dir = tempfile::tempdir()?;
2011        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2012        let Some(map_tile) = map_tile_cache
2013            .get_map_tile(&MapTileDescriptor::new(
2014                ZoomLevel::try_new(1)?,
2015                GridCoordinates::new(1136, 1075),
2016            ))
2017            .await?
2018        else {
2019            panic!("Expected there to be a region at this location");
2020        };
2021        tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
2022        #[expect(
2023            clippy::cast_precision_loss,
2024            reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
2025        )]
2026        for in_region_x in 0..=256 {
2027            for in_region_y in 0..=256 {
2028                let pixel_x = in_region_x;
2029                let pixel_y = 256 - in_region_y;
2030                tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2031                assert_eq!(
2032                    map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2033                    Some((
2034                        GridCoordinates::new(
2035                            1136 + if in_region_x == 256 { 1 } else { 0 },
2036                            1075 + if in_region_y == 256 { 1 } else { 0 }
2037                        ),
2038                        RegionCoordinates::new(
2039                            (in_region_x % 256) as f32,
2040                            (in_region_y % 256) as f32,
2041                            0f32
2042                        ),
2043                    ))
2044                );
2045            }
2046        }
2047        Ok(())
2048    }
2049
2050    #[traced_test]
2051    #[tokio::test]
2052    async fn test_map_coordinates_for_pixel_coordinates_four_regions()
2053    -> Result<(), Box<dyn std::error::Error>> {
2054        let temp_dir = tempfile::tempdir()?;
2055        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
2056        let mut map_tile_cache =
2057            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
2058        let map = Map::new(
2059            &mut map_tile_cache,
2060            512,
2061            512,
2062            GridRectangle::new(
2063                GridCoordinates::new(1136, 1074),
2064                GridCoordinates::new(1137, 1075),
2065            ),
2066            None,
2067            None,
2068        )
2069        .await?;
2070        tracing::debug!("Dimensions of map are {:?}", map.dimensions());
2071        for region_offset_x in 0..=1 {
2072            for region_offset_y in 0..=1 {
2073                for in_region_x in 0..=256 {
2074                    for in_region_y in 0..=256 {
2075                        let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
2076                        let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
2077                        tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2078                        assert_eq!(
2079                            map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2080                            Some((
2081                                GridCoordinates::new(
2082                                    1136 + region_offset_x + if in_region_x == 256 { 1 } else { 0 },
2083                                    1074 + region_offset_y + if in_region_y == 256 { 1 } else { 0 }
2084                                ),
2085                                RegionCoordinates::new(
2086                                    f32::from(in_region_x % 256),
2087                                    f32::from(in_region_y % 256),
2088                                    0f32
2089                                ),
2090                            )),
2091                        );
2092                    }
2093                }
2094            }
2095        }
2096        Ok(())
2097    }
2098
2099    /// background (untouched) pixel of a [`Map::new_blank_for_test`] image
2100    #[cfg(test)]
2101    const BLANK_PIXEL: [u8; 4] = [0, 0, 0, 0];
2102
2103    /// counts how many pixels of the map differ from the blank background
2104    #[cfg(test)]
2105    fn drawn_pixel_count(map: &Map) -> usize {
2106        map.image()
2107            .pixels()
2108            .filter(|(_, _, pixel)| pixel.0 != BLANK_PIXEL)
2109            .count()
2110    }
2111
2112    /// Two identical consecutive notecard lines produce a zero-distance segment.
2113    /// This exercises the pure drawing geometry (no network) and asserts it
2114    /// neither errors/panics nor corrupts the (0,0) corner with a NaN-derived
2115    /// rectangle, while still drawing the rest of the route.
2116    #[test]
2117    fn test_draw_route_identical_consecutive_waypoints_is_safe()
2118    -> Result<(), Box<dyn std::error::Error>> {
2119        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2120        let mut map = Map::new_blank_for_test(256, 256);
2121        // the middle pair is identical -> zero-distance segment
2122        let pixel_waypoints = vec![
2123            (100f32, 100f32),
2124            (120f32, 120f32),
2125            (120f32, 120f32),
2126            (140f32, 100f32),
2127        ];
2128        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2129        assert!(
2130            drawn_pixel_count(&map) > 0,
2131            "the route should still draw at least one pixel"
2132        );
2133        assert_eq!(
2134            map.image().get_pixel(0, 0).0,
2135            BLANK_PIXEL,
2136            "the (0,0) corner must stay background (no NaN-derived rectangle)"
2137        );
2138        Ok(())
2139    }
2140
2141    /// A two-waypoint route used to draw nothing because
2142    /// `spline_value_for_waypoint = i / (waypoint_count - 2)` divided by zero.
2143    /// It must now render a visible curve.
2144    #[test]
2145    fn test_draw_route_two_waypoints_renders() -> Result<(), Box<dyn std::error::Error>> {
2146        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2147        let mut map = Map::new_blank_for_test(256, 256);
2148        let pixel_waypoints = vec![(60f32, 60f32), (180f32, 180f32)];
2149        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2150        assert!(
2151            drawn_pixel_count(&map) > 0,
2152            "a two-waypoint route must draw a visible curve (regression for the waypoint_count - 2 == 0 bug)"
2153        );
2154        assert_eq!(
2155            map.image().get_pixel(0, 0).0,
2156            BLANK_PIXEL,
2157            "the (0,0) corner must stay background"
2158        );
2159        Ok(())
2160    }
2161
2162    /// When a segment produces exactly two sub-samples the old inner loop
2163    /// divided by `(samples - 2) == 0`, yielding a NaN parameter that saturated
2164    /// to coordinate 0 and drew a stray rectangle in the (0,0) corner. Two
2165    /// waypoints ~14 px apart give `samples == 2`; assert no corner glitch.
2166    #[test]
2167    fn test_draw_route_two_sample_segment_has_no_corner_glitch()
2168    -> Result<(), Box<dyn std::error::Error>> {
2169        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2170        let mut map = Map::new_blank_for_test(256, 256);
2171        let pixel_waypoints = vec![(100f32, 100f32), (110f32, 110f32)];
2172        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2173        assert_eq!(
2174            map.image().get_pixel(0, 0).0,
2175            BLANK_PIXEL,
2176            "samples == 2 must not draw a NaN-derived rectangle at the (0,0) corner"
2177        );
2178        Ok(())
2179    }
2180
2181    /// The bundled real route notecard contains an actual duplicate consecutive
2182    /// waypoint line; confirm the parser accepts it and the duplicate survives,
2183    /// since that is the real-world input that motivated the zero-distance work.
2184    #[test]
2185    fn test_real_notecard_with_duplicate_consecutive_lines_parses()
2186    -> Result<(), Box<dyn std::error::Error>> {
2187        let notecard: USBNotecard =
2188            include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2189        let has_identical_consecutive = notecard
2190            .waypoints()
2191            .windows(2)
2192            .any(|pair| matches!(pair, [first, second] if first.location() == second.location()));
2193        assert!(
2194            has_identical_consecutive,
2195            "tscc-2026-03-30.txt is expected to contain identical consecutive waypoints"
2196        );
2197        Ok(())
2198    }
2199
2200    /// End-to-end (network) smoke test: drawing a real route notecard that
2201    /// contains identical consecutive lines must complete without error.
2202    #[tokio::test]
2203    async fn test_draw_real_route_with_duplicate_line() -> Result<(), Box<dyn std::error::Error>> {
2204        let notecard: USBNotecard =
2205            include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2206        let temp_dir = tempfile::tempdir()?;
2207        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2208        let mut region_cache =
2209            RegionNameToGridCoordinatesCache::new(temp_dir.path().to_path_buf())?;
2210        let grid_rectangle =
2211            crate::region::usb_notecard_to_grid_rectangle(&mut region_cache, &notecard).await?;
2212        let mut map = Map::new(&mut map_tile_cache, 512, 512, grid_rectangle, None, None).await?;
2213        map.draw_route_with_progress(
2214            &mut region_cache,
2215            &notecard,
2216            image::Rgba([255u8, 0u8, 0u8, 255u8]),
2217            None,
2218        )
2219        .await?;
2220        Ok(())
2221    }
2222}