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