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: u32,
427        /// y grid coordinate of the region
428        y: u32,
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 =
1209            <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_x();
1210        let actual_y =
1211            <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * 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 = result.size_x().saturating_mul(result.size_y());
1253            emit_progress(
1254                progress,
1255                MapProgressEvent::RegionCheckPlanned { total_regions },
1256            );
1257        }
1258        for region_x in result.x_range() {
1259            for region_y in result.y_range() {
1260                let grid_coordinates = GridCoordinates::new(region_x, region_y);
1261                let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1262                let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1263                    return Err(MapError::NoOverlapError);
1264                };
1265                if overlap.lower_left_corner().x() != region_x
1266                    || overlap.lower_left_corner().y() != region_y
1267                {
1268                    // we should have already processed this map tile when
1269                    // we encountered the lower left corner of the overlap
1270                    continue;
1271                }
1272                tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1273                emit_progress(
1274                    progress,
1275                    MapProgressEvent::TileStarted {
1276                        descriptor: map_tile_descriptor.to_owned(),
1277                    },
1278                );
1279                let (fetched_tile, outcome) = map_tile_cache
1280                    .get_map_tile_with_outcome(&map_tile_descriptor)
1281                    .await?;
1282                emit_progress(
1283                    progress,
1284                    MapProgressEvent::TileFinished {
1285                        descriptor: map_tile_descriptor.to_owned(),
1286                        outcome,
1287                    },
1288                );
1289                if let Some(map_tile) = fetched_tile {
1290                    let crop = map_tile
1291                        .crop_imm_grid_rectangle(&overlap)
1292                        .ok_or(MapError::MapTileCropError)?;
1293                    tracing::debug!(
1294                        "Cropped map tile to ({}, {})+{}x{}",
1295                        crop.offsets().0,
1296                        crop.offsets().1,
1297                        (*crop).dimensions().0,
1298                        (*crop).dimensions().1
1299                    );
1300                    // we need to use y = 256 here since the crop is inserted by pixel coordinates which means
1301                    // we need the upper left corner, not the lower left one of the region as an origin
1302                    let (replace_x, replace_y) = result
1303                        .pixel_coordinates_for_coordinates(
1304                            &overlap.upper_left_corner(),
1305                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1306                        )
1307                        .ok_or(MapError::MapCoordinateError)?;
1308                    tracing::debug!(
1309                        "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1310                    );
1311                    image::imageops::replace(
1312                        &mut result,
1313                        &*crop,
1314                        replace_x.into(),
1315                        replace_y.into(),
1316                    );
1317                    if let Some(fill_color) = fill_missing_regions {
1318                        for overlap_region_x in overlap.x_range() {
1319                            for overlap_region_y in overlap.y_range() {
1320                                let grid_coordinates =
1321                                    GridCoordinates::new(overlap_region_x, overlap_region_y);
1322                                let exists =
1323                                    map_tile_cache.does_region_exist(&grid_coordinates).await?;
1324                                emit_progress(
1325                                    progress,
1326                                    MapProgressEvent::RegionChecked {
1327                                        x: overlap_region_x,
1328                                        y: overlap_region_y,
1329                                        exists,
1330                                    },
1331                                );
1332                                if !exists {
1333                                    let pixel_min = result.pixel_coordinates_for_coordinates(
1334                                        &grid_coordinates,
1335                                        &RegionCoordinates::new(0f32, 256f32, 0f32),
1336                                    );
1337                                    let pixel_max = result.pixel_coordinates_for_coordinates(
1338                                        &grid_coordinates,
1339                                        &RegionCoordinates::new(256f32, 0f32, 0f32),
1340                                    );
1341                                    if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1342                                        (pixel_min, pixel_max)
1343                                    {
1344                                        for x in min_x..max_x {
1345                                            for y in min_y..max_y {
1346                                                <Self as image::GenericImage>::put_pixel(
1347                                                    &mut result,
1348                                                    x,
1349                                                    y,
1350                                                    fill_color,
1351                                                );
1352                                            }
1353                                        }
1354                                    }
1355                                }
1356                            }
1357                        }
1358                    }
1359                } else if let Some(fill_color) = fill_missing_map_tiles {
1360                    let (replace_x, replace_y) = result
1361                        .pixel_coordinates_for_coordinates(
1362                            &overlap.upper_left_corner(),
1363                            &RegionCoordinates::new(0f32, 256f32, 0f32),
1364                        )
1365                        .ok_or(MapError::MapCoordinateError)?;
1366                    let pixel_size_x = overlap.size_x() * u32::from(zoom_level.pixels_per_region());
1367                    let pixel_size_y = overlap.size_y() * u32::from(zoom_level.pixels_per_region());
1368                    for x in replace_x..replace_x + pixel_size_x {
1369                        for y in replace_y..replace_y + pixel_size_y {
1370                            <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1371                        }
1372                    }
1373                }
1374            }
1375        }
1376        Ok(result)
1377    }
1378
1379    /// draws a route from a `USBNotecard` onto the map
1380    ///
1381    /// # Errors
1382    ///
1383    /// fails if the region name to grid coordinate conversion fails
1384    /// or the conversion of those into pixel coordinates
1385    pub async fn draw_route(
1386        &mut self,
1387        region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1388        usb_notecard: &USBNotecard,
1389        color: image::Rgba<u8>,
1390    ) -> Result<(), MapError> {
1391        self.draw_route_with_progress(
1392            region_name_to_grid_coordinates_cache,
1393            usb_notecard,
1394            color,
1395            None,
1396        )
1397        .await
1398    }
1399
1400    /// draws a route from a `USBNotecard` onto the map, emitting progress
1401    /// events for each waypoint resolved on the provided channel
1402    ///
1403    /// See [`Self::draw_route`] for the meaning of the other parameters.
1404    ///
1405    /// # Errors
1406    ///
1407    /// fails if the region name to grid coordinate conversion fails
1408    /// or the conversion of those into pixel coordinates
1409    pub async fn draw_route_with_progress(
1410        &mut self,
1411        region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1412        usb_notecard: &USBNotecard,
1413        color: image::Rgba<u8>,
1414        progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1415    ) -> Result<(), MapError> {
1416        tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1417        let waypoints = usb_notecard.waypoints();
1418        let total_waypoints = waypoints.len();
1419        emit_progress(progress, MapProgressEvent::RoutePlanned { total_waypoints });
1420        let mut pixel_waypoints = Vec::new();
1421        for (index, waypoint) in waypoints.iter().enumerate() {
1422            let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1423                .get_grid_coordinates(waypoint.location().region_name())
1424                .await?
1425            else {
1426                return Err(MapError::NoGridCoordinatesForRegion(
1427                    waypoint.location().region_name().to_owned(),
1428                ));
1429            };
1430            emit_progress(
1431                progress,
1432                MapProgressEvent::RouteWaypointResolved {
1433                    index,
1434                    total: total_waypoints,
1435                    region: waypoint.location().region_name().to_owned(),
1436                },
1437            );
1438            let (x, y) = self
1439                .pixel_coordinates_for_coordinates(
1440                    &grid_coordinates,
1441                    &waypoint.region_coordinates(),
1442                )
1443                .ok_or(MapError::MapCoordinateError)?;
1444            tracing::debug!(
1445                "Drawing waypoint at ({x}, {y}) for location {:?}",
1446                waypoint.location()
1447            );
1448            //self.draw_waypoint(x, y, color);
1449            #[expect(
1450                clippy::cast_precision_loss,
1451                reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1452            )]
1453            pixel_waypoints.push((x as f32, y as f32));
1454        }
1455        self.draw_pixel_waypoint_route(&pixel_waypoints, color)?;
1456        Ok(())
1457    }
1458
1459    /// draws a route through the given already-resolved pixel coordinates
1460    ///
1461    /// This is the pure geometry/rasterization half of
1462    /// [`Self::draw_route_with_progress`], split out so it can be unit tested
1463    /// without any network access (it only touches the image and the spline
1464    /// crate).
1465    ///
1466    /// It is public so callers that already hold resolved grid coordinates (for
1467    /// example to compute overlay occupancy via [`crate::coverage`]) can draw a
1468    /// route onto a [`Self::blank`] map without any network access, by first
1469    /// converting their coordinates with
1470    /// [`MapLike::pixel_coordinates_for_coordinates`].
1471    ///
1472    /// # Errors
1473    ///
1474    /// returns an error if the spline crate returns an error
1475    pub fn draw_pixel_waypoint_route(
1476        &mut self,
1477        pixel_waypoints: &[(f32, f32)],
1478        color: image::Rgba<u8>,
1479    ) -> Result<(), uniform_cubic_splines::SplineError> {
1480        let waypoint_count = pixel_waypoints.len();
1481        let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1482            // no route if there are no waypoints
1483            return Ok(());
1484        };
1485        let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1486        else {
1487            // no route if there is only one waypoint
1488            return Ok(());
1489        };
1490        let extra_before_start = (
1491            first.0 - (second.0 - first.0),
1492            first.1 - (second.1 - first.1),
1493        );
1494        let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1495            // no route if there are no waypoints (but this should never happen since we already returned at the first split_first() above)
1496            return Ok(());
1497        };
1498        let Some((second_to_last, _pixel_waypoints_rest)) =
1499            pixel_waypoints_all_but_last.split_last()
1500        else {
1501            // no route if there is only one waypoint (but this should never happen since we already returned at the second split_first() above)
1502            return Ok(());
1503        };
1504        let extra_after_end = (
1505            last.0 + (last.0 - second_to_last.0),
1506            last.1 + (last.1 - second_to_last.1),
1507        );
1508        let mut knots = vec![extra_before_start];
1509        knots.extend(pixel_waypoints.to_owned());
1510        knots.push(extra_after_end);
1511        let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1512        let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1513            let point_x =
1514                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1515                    v, &points_x,
1516                )?;
1517            let point_y =
1518                uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1519                    v, &points_y,
1520                )?;
1521            Ok((point_x, point_y))
1522        };
1523        // For the common case (>= 3 waypoints) the parameter that lands on
1524        // waypoint `i` keeps its historical value `i / (waypoint_count - 2)` so
1525        // route rendering is unchanged. For exactly 2 waypoints the old
1526        // denominator was 0 (NaN/inf), so use the mathematically correct uniform
1527        // mapping `i / (waypoint_count - 1)` (= `i` for n == 2), which places
1528        // waypoint 0 at x = 0 and waypoint 1 at x = 1.
1529        #[expect(
1530            clippy::cast_precision_loss,
1531            reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1532        )]
1533        let waypoint_parameter_denominator = if waypoint_count <= 2 {
1534            (waypoint_count as f32 - 1f32).max(1f32)
1535        } else {
1536            waypoint_count as f32 - 2f32
1537        };
1538        let spline_value_for_waypoint = |i: usize| -> f32 {
1539            #[expect(
1540                clippy::cast_precision_loss,
1541                reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1542            )]
1543            let i = i as f32;
1544            i / waypoint_parameter_denominator
1545        };
1546        let spline_value_between_waypoints = spline_value_for_waypoint(1);
1547        let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1548            ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1549        };
1550        let mut last_point: Option<(f32, f32)> = None;
1551        // For >= 3 waypoints we iterate over all but the last waypoint (the
1552        // historical behaviour). For exactly 2 waypoints we must also reach the
1553        // second waypoint (i == 1) so that `last_point` is `Some` and the curve
1554        // between the two waypoints is actually drawn (otherwise nothing renders).
1555        let outer_loop_count = if waypoint_count <= 2 {
1556            waypoint_count
1557        } else {
1558            waypoint_count - 1
1559        };
1560        for (i, waypoint) in pixel_waypoints.iter().enumerate().take(outer_loop_count) {
1561            /// size of rectangles to use to draw the spline, should be odd
1562            /// or it won't be centered properly
1563            const SPLINE_RECT_SIZE: u8 = 3;
1564            tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1565            let v = spline_value_for_waypoint(i);
1566            let point = sample(v)?;
1567            tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1568            if let Some(last_point) = last_point {
1569                let distance_from_last_point = distance_between_points(point, last_point);
1570                tracing::debug!(
1571                    "Waypoint {i} is {:?} from last waypoint",
1572                    distance_from_last_point
1573                );
1574                #[expect(
1575                    clippy::cast_possible_truncation,
1576                    reason = "we want an integer count for the number of samples"
1577                )]
1578                #[expect(
1579                    clippy::cast_sign_loss,
1580                    reason = "we want a positive count for the number of samples"
1581                )]
1582                let samples_between_last_waypoint_and_this_one =
1583                    (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1584                // The historical step uses `samples - 2` as the denominator so
1585                // that at j = samples - 1 the factor slightly exceeds 1 and the
1586                // drawing "overshoots" one sample past the previous waypoint for
1587                // gap-free coverage. That denominator is 0 when samples == 2
1588                // (NaN parameter -> stray rects at the clamped (0,0) corner), so
1589                // floor it to 1 in that single case. For samples >= 3 the value
1590                // is unchanged; samples <= 1 never enters this loop.
1591                #[expect(
1592                    clippy::cast_precision_loss,
1593                    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"
1594                )]
1595                let sample_step_denominator =
1596                    (samples_between_last_waypoint_and_this_one as f32 - 2f32).max(1f32);
1597                for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1598                    #[expect(
1599                        clippy::cast_precision_loss,
1600                        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"
1601                    )]
1602                    let v =
1603                        v - spline_value_between_waypoints * (j as f32 / sample_step_denominator);
1604                    let sample_point = sample(v)?;
1605                    #[expect(
1606                        clippy::cast_possible_truncation,
1607                        reason = "we want integer pixel coordinates for use in the image library"
1608                    )]
1609                    imageproc::drawing::draw_filled_rect_mut(
1610                        self.image_mut(),
1611                        imageproc::rect::Rect::at(
1612                            sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1613                            sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1614                        )
1615                        .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1616                        color,
1617                    );
1618                }
1619                self.draw_arrow(
1620                    sample(v - (0.1f32 * spline_value_between_waypoints))?,
1621                    point,
1622                    color,
1623                );
1624            }
1625            last_point = Some(point);
1626        }
1627        Ok(())
1628    }
1629
1630    /// creates a blank `Map` with a fully transparent RGBA image sized for the
1631    /// given grid rectangle at the given zoom level, without any network access.
1632    ///
1633    /// Unlike [`Self::new`] (which builds an RGB8 base map from fetched tiles)
1634    /// this uses an RGBA8 image so untouched pixels have alpha 0. That is what
1635    /// the [`crate::coverage`] occupancy analysis relies on to tell pixels that
1636    /// were drawn on (route, GLW shapes/labels) apart from blank ones. The pixel
1637    /// geometry (dimensions and
1638    /// [`MapLike::pixel_coordinates_for_coordinates`]) depends only on the zoom
1639    /// level and grid rectangle, not the channel count, so drawing overlays onto
1640    /// this blank map yields exactly the same pixel positions as the real render.
1641    #[must_use]
1642    pub fn blank(grid_rectangle: GridRectangle, zoom_level: ZoomLevel) -> Self {
1643        let width =
1644            <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_x();
1645        let height =
1646            <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_y();
1647        Self {
1648            zoom_level,
1649            grid_rectangle,
1650            image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1651        }
1652    }
1653
1654    /// creates a blank `Map` sized exactly as [`Self::new`] would size it for the
1655    /// given maximum output dimensions, without any network access.
1656    ///
1657    /// This reproduces the zoom-fit logic of [`Self::new_with_progress`] so the
1658    /// resulting blank map has the identical pixel dimensions the real render
1659    /// would have for the same `grid_rectangle` and the same caps. See
1660    /// [`Self::blank`] for why the image is RGBA8.
1661    ///
1662    /// # Errors
1663    ///
1664    /// returns an error if the zoom level that fits the rectangle into the
1665    /// output image cannot be calculated
1666    #[expect(
1667        clippy::result_large_err,
1668        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"
1669    )]
1670    pub fn blank_fit(
1671        grid_rectangle: GridRectangle,
1672        max_width: u32,
1673        max_height: u32,
1674    ) -> Result<Self, MapError> {
1675        let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1676            grid_rectangle.size_x(),
1677            grid_rectangle.size_y(),
1678            max_width,
1679            max_height,
1680        )?;
1681        Ok(Self::blank(grid_rectangle, zoom_level))
1682    }
1683
1684    /// creates a blank `Map` with a transparent image of the given size for
1685    /// use in tests that exercise the pure drawing geometry without any network
1686    /// access
1687    #[cfg(test)]
1688    fn new_blank_for_test(width: u32, height: u32) -> Self {
1689        #[expect(
1690            clippy::expect_used,
1691            reason = "zoom level 1 is a compile-time constant within the valid range"
1692        )]
1693        let zoom_level = ZoomLevel::try_new(1).expect("zoom level 1 is within the valid range");
1694        Self {
1695            zoom_level,
1696            grid_rectangle: GridRectangle::new(
1697                GridCoordinates::new(0, 0),
1698                GridCoordinates::new(0, 0),
1699            ),
1700            image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1701        }
1702    }
1703
1704    /// saves the map to the specified path
1705    ///
1706    /// # Errors
1707    ///
1708    /// returns an error when the image libraries returns an error
1709    /// when saving the image
1710    pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1711        self.image.save(path)
1712    }
1713}
1714
1715impl GridRectangleLike for Map {
1716    fn grid_rectangle(&self) -> GridRectangle {
1717        self.grid_rectangle.to_owned()
1718    }
1719}
1720
1721impl image::GenericImageView for Map {
1722    type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1723
1724    fn dimensions(&self) -> (u32, u32) {
1725        self.image.dimensions()
1726    }
1727
1728    fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1729        self.image.get_pixel(x, y)
1730    }
1731}
1732
1733impl image::GenericImage for Map {
1734    fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1735        #[expect(
1736            deprecated,
1737            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1738        )]
1739        self.image.get_pixel_mut(x, y)
1740    }
1741
1742    fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1743        self.image.put_pixel(x, y, pixel);
1744    }
1745
1746    fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1747        #[expect(
1748            deprecated,
1749            reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1750        )]
1751        self.image.blend_pixel(x, y, pixel);
1752    }
1753}
1754
1755impl MapLike for Map {
1756    fn zoom_level(&self) -> ZoomLevel {
1757        self.zoom_level
1758    }
1759
1760    fn image(&self) -> &image::DynamicImage {
1761        &self.image
1762    }
1763
1764    fn image_mut(&mut self) -> &mut image::DynamicImage {
1765        &mut self.image
1766    }
1767}
1768
1769#[cfg(test)]
1770mod test {
1771    use image::GenericImageView as _;
1772    use tracing_test::traced_test;
1773
1774    use super::*;
1775
1776    #[tokio::test]
1777    async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1778        let temp_dir = tempfile::tempdir()?;
1779        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1780        map_tile_cache
1781            .get_map_tile(&MapTileDescriptor::new(
1782                ZoomLevel::try_new(1)?,
1783                GridCoordinates::new(1136, 1075),
1784            ))
1785            .await?;
1786        Ok(())
1787    }
1788
1789    #[tokio::test]
1790    async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1791        let temp_dir = tempfile::tempdir()?;
1792        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1793        map_tile_cache
1794            .get_map_tile(&MapTileDescriptor::new(
1795                ZoomLevel::try_new(1)?,
1796                GridCoordinates::new(1136, 1075),
1797            ))
1798            .await?;
1799        map_tile_cache
1800            .get_map_tile(&MapTileDescriptor::new(
1801                ZoomLevel::try_new(1)?,
1802                GridCoordinates::new(1136, 1075),
1803            ))
1804            .await?;
1805        Ok(())
1806    }
1807
1808    #[tokio::test]
1809    async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1810        let temp_dir = tempfile::tempdir()?;
1811        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1812        map_tile_cache
1813            .get_map_tile(&MapTileDescriptor::new(
1814                ZoomLevel::try_new(8)?,
1815                GridCoordinates::new(1136, 1075),
1816            ))
1817            .await?;
1818        Ok(())
1819    }
1820
1821    #[traced_test]
1822    #[tokio::test]
1823    async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1824        let temp_dir = tempfile::tempdir()?;
1825        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1826        let mut map_tile_cache =
1827            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1828        let map = Map::new(
1829            &mut map_tile_cache,
1830            512,
1831            512,
1832            GridRectangle::new(
1833                GridCoordinates::new(1135, 1070),
1834                GridCoordinates::new(1136, 1071),
1835            ),
1836            None,
1837            None,
1838        )
1839        .await?;
1840        map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1841        Ok(())
1842    }
1843
1844    #[traced_test]
1845    #[tokio::test]
1846    async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1847        let temp_dir = tempfile::tempdir()?;
1848        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1849        let mut map_tile_cache =
1850            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1851        let map = Map::new(
1852            &mut map_tile_cache,
1853            256,
1854            256,
1855            GridRectangle::new(
1856                GridCoordinates::new(1136, 1074),
1857                GridCoordinates::new(1137, 1075),
1858            ),
1859            None,
1860            None,
1861        )
1862        .await?;
1863        map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1864        Ok(())
1865    }
1866
1867    #[traced_test]
1868    #[tokio::test]
1869    async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1870        let temp_dir = tempfile::tempdir()?;
1871        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1872        let mut map_tile_cache =
1873            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1874        let map = Map::new(
1875            &mut map_tile_cache,
1876            128,
1877            128,
1878            GridRectangle::new(
1879                GridCoordinates::new(1136, 1074),
1880                GridCoordinates::new(1137, 1075),
1881            ),
1882            None,
1883            None,
1884        )
1885        .await?;
1886        map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1887        Ok(())
1888    }
1889
1890    #[traced_test]
1891    #[tokio::test]
1892    async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1893        let temp_dir = tempfile::tempdir()?;
1894        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1895        let mut map_tile_cache =
1896            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1897        let map = Map::new(
1898            &mut map_tile_cache,
1899            2048,
1900            2048,
1901            GridRectangle::new(
1902                GridCoordinates::new(1131, 1068),
1903                GridCoordinates::new(1139, 1075),
1904            ),
1905            None,
1906            None,
1907        )
1908        .await?;
1909        map.save(std::path::Path::new(
1910            "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1911        ))?;
1912        Ok(())
1913    }
1914
1915    #[traced_test]
1916    #[tokio::test]
1917    #[expect(clippy::panic, reason = "panic in test is intentional")]
1918    async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1919    -> Result<(), Box<dyn std::error::Error>> {
1920        let temp_dir = tempfile::tempdir()?;
1921        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1922        let Some(map_tile) = map_tile_cache
1923            .get_map_tile(&MapTileDescriptor::new(
1924                ZoomLevel::try_new(1)?,
1925                GridCoordinates::new(1136, 1075),
1926            ))
1927            .await?
1928        else {
1929            panic!("Expected there to be a region at this location");
1930        };
1931        for in_region_x in 0..=256 {
1932            for in_region_y in 0..=256 {
1933                let grid_coordinates = GridCoordinates::new(1136, 1075);
1934                #[expect(
1935                    clippy::cast_precision_loss,
1936                    reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1937                )]
1938                let region_coordinates =
1939                    RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
1940                tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
1941                assert_eq!(
1942                    map_tile
1943                        .pixel_coordinates_for_coordinates(&grid_coordinates, &region_coordinates,),
1944                    Some((in_region_x, 256 - in_region_y)),
1945                );
1946            }
1947        }
1948        Ok(())
1949    }
1950
1951    #[traced_test]
1952    #[tokio::test]
1953    async fn test_map_pixel_coordinates_for_coordinates_four_regions()
1954    -> Result<(), Box<dyn std::error::Error>> {
1955        let temp_dir = tempfile::tempdir()?;
1956        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1957        let mut map_tile_cache =
1958            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1959        let map = Map::new(
1960            &mut map_tile_cache,
1961            512,
1962            512,
1963            GridRectangle::new(
1964                GridCoordinates::new(1136, 1074),
1965                GridCoordinates::new(1137, 1075),
1966            ),
1967            None,
1968            None,
1969        )
1970        .await?;
1971        for region_offset_x in 0u16..=1 {
1972            for region_offset_y in 0u16..=1 {
1973                for in_region_x in 0u16..=256 {
1974                    for in_region_y in 0u16..=256 {
1975                        let grid_coordinates = GridCoordinates::new(
1976                            u32::from(1136 + region_offset_x),
1977                            u32::from(1074 + region_offset_y),
1978                        );
1979                        let region_coordinates = RegionCoordinates::new(
1980                            f32::from(in_region_x),
1981                            f32::from(in_region_y),
1982                            0f32,
1983                        );
1984                        tracing::debug!(
1985                            "Now checking {grid_coordinates:?}, {region_coordinates:?}"
1986                        );
1987                        assert_eq!(
1988                            map.pixel_coordinates_for_coordinates(
1989                                &grid_coordinates,
1990                                &region_coordinates,
1991                            ),
1992                            Some((
1993                                u32::from(region_offset_x * 256 + in_region_x),
1994                                u32::from(512 - (region_offset_y * 256 + in_region_y))
1995                            )),
1996                        );
1997                    }
1998                }
1999            }
2000        }
2001        Ok(())
2002    }
2003
2004    #[traced_test]
2005    #[tokio::test]
2006    #[expect(clippy::panic, reason = "panic in test is intentional")]
2007    async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
2008    -> Result<(), Box<dyn std::error::Error>> {
2009        let temp_dir = tempfile::tempdir()?;
2010        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2011        let Some(map_tile) = map_tile_cache
2012            .get_map_tile(&MapTileDescriptor::new(
2013                ZoomLevel::try_new(1)?,
2014                GridCoordinates::new(1136, 1075),
2015            ))
2016            .await?
2017        else {
2018            panic!("Expected there to be a region at this location");
2019        };
2020        tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
2021        #[expect(
2022            clippy::cast_precision_loss,
2023            reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
2024        )]
2025        for in_region_x in 0..=256 {
2026            for in_region_y in 0..=256 {
2027                let pixel_x = in_region_x;
2028                let pixel_y = 256 - in_region_y;
2029                tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2030                assert_eq!(
2031                    map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2032                    Some((
2033                        GridCoordinates::new(
2034                            1136 + if in_region_x == 256 { 1 } else { 0 },
2035                            1075 + if in_region_y == 256 { 1 } else { 0 }
2036                        ),
2037                        RegionCoordinates::new(
2038                            (in_region_x % 256) as f32,
2039                            (in_region_y % 256) as f32,
2040                            0f32
2041                        ),
2042                    ))
2043                );
2044            }
2045        }
2046        Ok(())
2047    }
2048
2049    #[traced_test]
2050    #[tokio::test]
2051    async fn test_map_coordinates_for_pixel_coordinates_four_regions()
2052    -> Result<(), Box<dyn std::error::Error>> {
2053        let temp_dir = tempfile::tempdir()?;
2054        let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
2055        let mut map_tile_cache =
2056            MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
2057        let map = Map::new(
2058            &mut map_tile_cache,
2059            512,
2060            512,
2061            GridRectangle::new(
2062                GridCoordinates::new(1136, 1074),
2063                GridCoordinates::new(1137, 1075),
2064            ),
2065            None,
2066            None,
2067        )
2068        .await?;
2069        tracing::debug!("Dimensions of map are {:?}", map.dimensions());
2070        for region_offset_x in 0u16..=1 {
2071            for region_offset_y in 0u16..=1 {
2072                for in_region_x in 0u16..=256 {
2073                    for in_region_y in 0u16..=256 {
2074                        let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
2075                        let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
2076                        tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2077                        assert_eq!(
2078                            map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2079                            Some((
2080                                GridCoordinates::new(
2081                                    u32::from(
2082                                        1136 + region_offset_x
2083                                            + if in_region_x == 256 { 1 } else { 0 }
2084                                    ),
2085                                    u32::from(
2086                                        1074 + region_offset_y
2087                                            + if in_region_y == 256 { 1 } else { 0 }
2088                                    )
2089                                ),
2090                                RegionCoordinates::new(
2091                                    f32::from(in_region_x % 256),
2092                                    f32::from(in_region_y % 256),
2093                                    0f32
2094                                ),
2095                            )),
2096                        );
2097                    }
2098                }
2099            }
2100        }
2101        Ok(())
2102    }
2103
2104    /// background (untouched) pixel of a [`Map::new_blank_for_test`] image
2105    #[cfg(test)]
2106    const BLANK_PIXEL: [u8; 4] = [0, 0, 0, 0];
2107
2108    /// counts how many pixels of the map differ from the blank background
2109    #[cfg(test)]
2110    fn drawn_pixel_count(map: &Map) -> usize {
2111        map.image()
2112            .pixels()
2113            .filter(|(_, _, pixel)| pixel.0 != BLANK_PIXEL)
2114            .count()
2115    }
2116
2117    /// Two identical consecutive notecard lines produce a zero-distance segment.
2118    /// This exercises the pure drawing geometry (no network) and asserts it
2119    /// neither errors/panics nor corrupts the (0,0) corner with a NaN-derived
2120    /// rectangle, while still drawing the rest of the route.
2121    #[test]
2122    fn test_draw_route_identical_consecutive_waypoints_is_safe()
2123    -> Result<(), Box<dyn std::error::Error>> {
2124        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2125        let mut map = Map::new_blank_for_test(256, 256);
2126        // the middle pair is identical -> zero-distance segment
2127        let pixel_waypoints = vec![
2128            (100f32, 100f32),
2129            (120f32, 120f32),
2130            (120f32, 120f32),
2131            (140f32, 100f32),
2132        ];
2133        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2134        assert!(
2135            drawn_pixel_count(&map) > 0,
2136            "the route should still draw at least one pixel"
2137        );
2138        assert_eq!(
2139            map.image().get_pixel(0, 0).0,
2140            BLANK_PIXEL,
2141            "the (0,0) corner must stay background (no NaN-derived rectangle)"
2142        );
2143        Ok(())
2144    }
2145
2146    /// A two-waypoint route used to draw nothing because
2147    /// `spline_value_for_waypoint = i / (waypoint_count - 2)` divided by zero.
2148    /// It must now render a visible curve.
2149    #[test]
2150    fn test_draw_route_two_waypoints_renders() -> Result<(), Box<dyn std::error::Error>> {
2151        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2152        let mut map = Map::new_blank_for_test(256, 256);
2153        let pixel_waypoints = vec![(60f32, 60f32), (180f32, 180f32)];
2154        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2155        assert!(
2156            drawn_pixel_count(&map) > 0,
2157            "a two-waypoint route must draw a visible curve (regression for the waypoint_count - 2 == 0 bug)"
2158        );
2159        assert_eq!(
2160            map.image().get_pixel(0, 0).0,
2161            BLANK_PIXEL,
2162            "the (0,0) corner must stay background"
2163        );
2164        Ok(())
2165    }
2166
2167    /// When a segment produces exactly two sub-samples the old inner loop
2168    /// divided by `(samples - 2) == 0`, yielding a NaN parameter that saturated
2169    /// to coordinate 0 and drew a stray rectangle in the (0,0) corner. Two
2170    /// waypoints ~14 px apart give `samples == 2`; assert no corner glitch.
2171    #[test]
2172    fn test_draw_route_two_sample_segment_has_no_corner_glitch()
2173    -> Result<(), Box<dyn std::error::Error>> {
2174        let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2175        let mut map = Map::new_blank_for_test(256, 256);
2176        let pixel_waypoints = vec![(100f32, 100f32), (110f32, 110f32)];
2177        map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2178        assert_eq!(
2179            map.image().get_pixel(0, 0).0,
2180            BLANK_PIXEL,
2181            "samples == 2 must not draw a NaN-derived rectangle at the (0,0) corner"
2182        );
2183        Ok(())
2184    }
2185
2186    /// The bundled real route notecard contains an actual duplicate consecutive
2187    /// waypoint line; confirm the parser accepts it and the duplicate survives,
2188    /// since that is the real-world input that motivated the zero-distance work.
2189    #[test]
2190    fn test_real_notecard_with_duplicate_consecutive_lines_parses()
2191    -> Result<(), Box<dyn std::error::Error>> {
2192        let notecard: USBNotecard =
2193            include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2194        let has_identical_consecutive = notecard
2195            .waypoints()
2196            .windows(2)
2197            .any(|pair| matches!(pair, [first, second] if first.location() == second.location()));
2198        assert!(
2199            has_identical_consecutive,
2200            "tscc-2026-03-30.txt is expected to contain identical consecutive waypoints"
2201        );
2202        Ok(())
2203    }
2204
2205    /// End-to-end (network) smoke test: drawing a real route notecard that
2206    /// contains identical consecutive lines must complete without error.
2207    #[tokio::test]
2208    async fn test_draw_real_route_with_duplicate_line() -> Result<(), Box<dyn std::error::Error>> {
2209        let notecard: USBNotecard =
2210            include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2211        let temp_dir = tempfile::tempdir()?;
2212        let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2213        let mut region_cache =
2214            RegionNameToGridCoordinatesCache::new(temp_dir.path().to_path_buf())?;
2215        let grid_rectangle =
2216            crate::region::usb_notecard_to_grid_rectangle(&mut region_cache, &notecard).await?;
2217        let mut map = Map::new(&mut map_tile_cache, 512, 512, grid_rectangle, None, None).await?;
2218        map.draw_route_with_progress(
2219            &mut region_cache,
2220            &notecard,
2221            image::Rgba([255u8, 0u8, 0u8, 255u8]),
2222            None,
2223        )
2224        .await?;
2225        Ok(())
2226    }
2227}