1use 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
12pub trait MapLike: GridRectangleLike + image::GenericImage + image::GenericImageView {
15 #[must_use]
17 fn image(&self) -> &image::DynamicImage;
18
19 #[must_use]
21 fn image_mut(&mut self) -> &mut image::DynamicImage;
22
23 #[must_use]
25 fn zoom_level(&self) -> ZoomLevel;
26
27 #[must_use]
29 fn pixels_per_meter(&self) -> f32 {
30 self.zoom_level().pixels_per_meter()
31 }
32
33 #[must_use]
35 fn pixels_per_region(&self) -> f32 {
36 self.pixels_per_meter() * 256f32
37 }
38
39 #[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 #[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 #[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 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 fn draw_line(
194 &mut self,
195 from_x: u32,
196 from_y: u32,
197 to_x: u32,
198 to_y: u32,
199 color: image::Rgba<u8>,
200 ) {
201 if from_x == to_x && from_y == to_y {
202 return;
205 }
206 #[expect(
207 clippy::cast_precision_loss,
208 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
209 )]
210 let from_x = from_x as f32;
211 #[expect(
212 clippy::cast_precision_loss,
213 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
214 )]
215 let from_y = from_y as f32;
216 #[expect(
217 clippy::cast_precision_loss,
218 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
219 )]
220 let to_x = to_x as f32;
221 #[expect(
222 clippy::cast_precision_loss,
223 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
224 )]
225 let to_y = to_y as f32;
226 let diff = (to_x - from_x, to_y - from_y);
227 let perpendicular = (-diff.1, diff.0);
228 let magnitude = (diff.0.powi(2) + diff.1.powi(2)).sqrt();
229 let perpendicular_normalized = (perpendicular.0 / magnitude, perpendicular.1 / magnitude);
230 #[expect(
231 clippy::cast_possible_truncation,
232 reason = "we want integer coordinates for use in Points"
233 )]
234 let points = vec![
235 imageproc::point::Point::new(
236 (from_x + perpendicular_normalized.0 * 5.0) as i32,
237 (from_y + perpendicular_normalized.1 * 5.0) as i32,
238 ),
239 imageproc::point::Point::new(
240 (to_x + perpendicular_normalized.0 * 5.0) as i32,
241 (to_y + perpendicular_normalized.1 * 5.0) as i32,
242 ),
243 imageproc::point::Point::new(
244 (to_x - perpendicular_normalized.0 * 5.0) as i32,
245 (to_y - perpendicular_normalized.1 * 5.0) as i32,
246 ),
247 imageproc::point::Point::new(
248 (from_x - perpendicular_normalized.0 * 5.0) as i32,
249 (from_y - perpendicular_normalized.1 * 5.0) as i32,
250 ),
251 ];
252 imageproc::drawing::draw_antialiased_polygon_mut(
253 self.image_mut(),
254 &points,
255 color,
256 imageproc::pixelops::interpolate,
257 );
258 }
259
260 fn draw_arrow(&mut self, from: (f32, f32), tip: (f32, f32), color: image::Rgba<u8>) {
263 const ARROW_LENGTH: f32 = 15f32;
265 const ARROW_HALF_WIDTH: f32 = 5f32;
267 if from == tip {
268 return;
270 }
271 let arrow_direction = (tip.0 - from.0, tip.1 - from.1);
272 let arrow_direction_magnitude =
273 (arrow_direction.0.powf(2f32) + arrow_direction.1.powf(2f32)).sqrt();
274 let arrow_direction = (
275 arrow_direction.0 / arrow_direction_magnitude,
276 arrow_direction.1 / arrow_direction_magnitude,
277 );
278 let arrow_base_middle = (
279 tip.0 - (ARROW_LENGTH * arrow_direction.0),
280 tip.1 - (ARROW_LENGTH * arrow_direction.1),
281 );
282 let arrow_base_side1 = (
283 arrow_base_middle.0 + (ARROW_HALF_WIDTH * arrow_direction.1),
284 arrow_base_middle.1 - (ARROW_HALF_WIDTH * arrow_direction.0),
285 );
286 let arrow_base_side2 = (
287 arrow_base_middle.0 - (ARROW_HALF_WIDTH * arrow_direction.1),
288 arrow_base_middle.1 + (ARROW_HALF_WIDTH * arrow_direction.0),
289 );
290 tracing::debug!(
291 "Painting arrow with arrow direction {:?}, arrow tip {:?}, arrow base middle {:?}, arrow_base_side1 {:?}, arrow_base_side2 {:?} ",
292 arrow_direction,
293 tip,
294 arrow_base_middle,
295 arrow_base_side1,
296 arrow_base_side2
297 );
298 #[expect(
299 clippy::cast_possible_truncation,
300 reason = "we want integer coordinates for use in Points"
301 )]
302 imageproc::drawing::draw_polygon_mut(
303 self.image_mut(),
304 &[
305 imageproc::point::Point::new(arrow_base_side1.0 as i32, arrow_base_side1.1 as i32),
306 imageproc::point::Point::new(tip.0 as i32, tip.1 as i32),
307 imageproc::point::Point::new(arrow_base_side2.0 as i32, arrow_base_side2.1 as i32),
308 ],
309 color,
310 );
311 }
312}
313
314#[derive(Debug, Clone)]
316pub struct MapTile {
317 descriptor: MapTileDescriptor,
319
320 image: image::DynamicImage,
322}
323
324impl MapTile {
325 #[must_use]
327 pub const fn descriptor(&self) -> &MapTileDescriptor {
328 &self.descriptor
329 }
330}
331
332impl GridRectangleLike for MapTile {
333 fn grid_rectangle(&self) -> GridRectangle {
334 self.descriptor.grid_rectangle()
335 }
336}
337
338impl image::GenericImageView for MapTile {
339 type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
340
341 fn dimensions(&self) -> (u32, u32) {
342 self.image.dimensions()
343 }
344
345 fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
346 self.image.get_pixel(x, y)
347 }
348}
349
350impl image::GenericImage for MapTile {
351 fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
352 #[expect(
353 deprecated,
354 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
355 )]
356 self.image.get_pixel_mut(x, y)
357 }
358
359 fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
360 self.image.put_pixel(x, y, pixel);
361 }
362
363 fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
364 #[expect(
365 deprecated,
366 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
367 )]
368 self.image.blend_pixel(x, y, pixel);
369 }
370}
371
372impl MapLike for MapTile {
373 fn zoom_level(&self) -> ZoomLevel {
374 self.descriptor.zoom_level().to_owned()
375 }
376
377 fn image(&self) -> &image::DynamicImage {
378 &self.image
379 }
380
381 fn image_mut(&mut self) -> &mut image::DynamicImage {
382 &mut self.image
383 }
384}
385
386#[derive(Debug, thiserror::Error)]
388pub enum MapTileCacheError {
389 #[error("error manipulating files in the cache directory: {0}")]
391 CacheDirectoryFileError(std::io::Error),
392 #[error("reqwest error when fetching the map tile from the server: {0}")]
394 ReqwestError(#[from] reqwest::Error),
395 #[error("HTTP request is not success: URL {0} response status {1} headers {2:#?} body {3}")]
397 HttpError(
398 String,
399 reqwest::StatusCode,
400 reqwest::header::HeaderMap,
401 String,
402 ),
403 #[error("failed to clone request for cache policy")]
406 FailedToCloneRequest,
407 #[error("error guessing image format: {0}")]
409 ImageFormatGuessError(std::io::Error),
410 #[error("error reading the raw map tile into an image: {0}")]
412 ImageError(#[from] image::ImageError),
413 #[error("error decoding the JSON serialized CachePolicy: {0}")]
415 CachePolicyJsonDecodeError(#[from] serde_json::Error),
416 #[error("error creating a zoom level: {0}")]
418 ZoomLevelError(#[from] ZoomLevelError),
419 #[error("error when trying to load cache policy that we previously checked existed on disk")]
422 CachePolicyError,
423}
424
425#[derive(derive_more::Debug)]
427pub struct MapTileCache {
428 client: reqwest::Client,
430 #[debug(skip)]
432 ratelimiter: Option<ratelimit::Ratelimiter>,
433 cache_directory: PathBuf,
435 #[debug(skip)]
437 cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum MapTileCacheEntryStatus {
443 Missing,
445 Invalid,
447 Valid,
449}
450
451#[derive(Debug)]
454pub struct MapTileNegativeResponse(reqwest::Response);
455
456impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
457 fn status(&self) -> http::status::StatusCode {
458 match self.0.status() {
459 http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
460 status => status,
461 }
462 }
463
464 fn headers(&self) -> &http::header::HeaderMap {
465 self.0.headers()
466 }
467}
468
469impl MapTileCache {
470 #[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
472 #[must_use]
473 pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
474 #[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
475 let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
476 Self {
477 client: reqwest::Client::new(),
478 ratelimiter,
479 cache_directory,
480 cache,
481 }
482 }
483
484 #[must_use]
486 fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
487 format!(
488 "map-{}-{}-{}-objects.jpg",
489 map_tile_descriptor.zoom_level(),
490 map_tile_descriptor.lower_left_corner().x(),
491 map_tile_descriptor.lower_left_corner().y(),
492 )
493 }
494
495 #[must_use]
497 fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
498 self.cache_directory
499 .join(Self::map_tile_file_name(map_tile_descriptor))
500 }
501
502 #[must_use]
504 fn map_tile_cache_negative_response_file_name(
505 &self,
506 map_tile_descriptor: &MapTileDescriptor,
507 ) -> PathBuf {
508 self.cache_directory.join(format!(
509 "{}.does-not-exist",
510 Self::map_tile_file_name(map_tile_descriptor)
511 ))
512 }
513
514 #[must_use]
516 fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
517 self.cache_directory.join(format!(
518 "{}.cache-policy.json",
519 Self::map_tile_file_name(map_tile_descriptor)
520 ))
521 }
522
523 #[must_use]
525 fn map_tile_url(map_tile_descriptor: &MapTileDescriptor) -> String {
526 format!(
527 "https://secondlife-maps-cdn.akamaized.net/{}",
528 Self::map_tile_file_name(map_tile_descriptor),
529 )
530 }
531
532 async fn cache_entry_status(
534 &self,
535 map_tile_descriptor: &MapTileDescriptor,
536 ) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
537 match (
538 self.cache_policy_file_name(map_tile_descriptor).exists(),
539 self.map_tile_cache_file_name(map_tile_descriptor).exists(),
540 self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
541 .exists(),
542 ) {
543 (false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
544 (true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
545 (cp, tile, neg) => {
546 tracing::warn!(
547 "cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
548 cp,
549 tile,
550 neg
551 );
552 Ok(MapTileCacheEntryStatus::Invalid)
553 }
554 }
555 }
556
557 async fn fetch_cached_map_tile(
564 &mut self,
565 map_tile_descriptor: &MapTileDescriptor,
566 ) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
567 {
568 if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
569 return Ok(Some(cache_entry.to_owned()));
570 }
571 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
572 let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
573 if cache_entry_status == MapTileCacheEntryStatus::Invalid {
574 self.remove_cached_tile(map_tile_descriptor).await?;
575 return Ok(None);
576 }
577 if cache_entry_status == MapTileCacheEntryStatus::Missing {
578 return Ok(None);
579 }
580 let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
581 return Err(MapTileCacheError::CachePolicyError);
582 };
583 if cache_file.exists() {
584 let cached_map_tile = image::ImageReader::open(cache_file)
585 .map_err(MapTileCacheError::CacheDirectoryFileError)?
586 .decode()?;
587 Ok(Some((
588 Some(MapTile {
589 descriptor: map_tile_descriptor.to_owned(),
590 image: cached_map_tile,
591 }),
592 cache_policy,
593 )))
594 } else {
595 Ok(Some((None, cache_policy)))
597 }
598 }
599
600 async fn remove_cached_tile(
602 &mut self,
603 map_tile_descriptor: &MapTileDescriptor,
604 ) -> Result<(), MapTileCacheError> {
605 tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
606 self.cache.pop(map_tile_descriptor);
607 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
608 let cache_file_negative_response =
609 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
610 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
611 if cache_file.exists() {
612 std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
613 }
614 if cache_file_negative_response.exists() {
615 std::fs::remove_file(cache_file_negative_response)
616 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
617 }
618 if cache_policy_file.exists() {
619 std::fs::remove_file(cache_policy_file)
620 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
621 }
622 Ok(())
623 }
624
625 async fn load_cache_policy(
632 &self,
633 map_tile_descriptor: &MapTileDescriptor,
634 ) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
635 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
636 if !cache_policy_file.exists() {
637 return Ok(None);
638 }
639 let cache_policy = std::fs::read_to_string(cache_policy_file)
640 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
641 Ok(serde_json::from_str(&cache_policy)?)
642 }
643
644 async fn store_cache_policy(
651 &self,
652 map_tile_descriptor: &MapTileDescriptor,
653 cache_policy: http_cache_semantics::CachePolicy,
654 ) -> Result<(), MapTileCacheError> {
655 if !self.cache_directory.exists() {
656 std::fs::create_dir_all(&self.cache_directory)
657 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
658 }
659 let cache_policy = serde_json::to_string(&cache_policy)?;
660 std::fs::write(
661 self.cache_policy_file_name(map_tile_descriptor),
662 cache_policy,
663 )
664 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
665 Ok(())
666 }
667
668 async fn cache_missing_tile(
676 &mut self,
677 map_tile_descriptor: &MapTileDescriptor,
678 cache_policy: http_cache_semantics::CachePolicy,
679 ) -> Result<(), MapTileCacheError> {
680 if cache_policy.is_storable() {
681 tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
682 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
683 .await?;
684 let cache_file_negative_response =
685 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
686 std::fs::File::create(cache_file_negative_response)
687 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
688 self.cache
689 .put(map_tile_descriptor.clone(), (None, cache_policy));
690 } else {
691 tracing::warn!(
692 "Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
693 );
694 }
695 Ok(())
696 }
697
698 async fn cache_tile(
706 &mut self,
707 map_tile_descriptor: &MapTileDescriptor,
708 map_tile: &MapTile,
709 cache_policy: http_cache_semantics::CachePolicy,
710 ) -> Result<(), MapTileCacheError> {
711 if cache_policy.is_storable() {
712 tracing::debug!("Caching map tile {map_tile_descriptor:?}");
713 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
714 .await?;
715 map_tile
716 .image
717 .save(self.map_tile_cache_file_name(map_tile_descriptor))?;
718 self.cache.put(
719 map_tile_descriptor.clone(),
720 (Some(map_tile.to_owned()), cache_policy),
721 );
722 } else {
723 tracing::warn!(
724 "Map tile {map_tile_descriptor:?} not storable according to cache policy"
725 );
726 }
727 Ok(())
728 }
729
730 pub async fn get_map_tile(
738 &mut self,
739 map_tile_descriptor: &MapTileDescriptor,
740 ) -> Result<Option<MapTile>, MapTileCacheError> {
741 tracing::debug!("Map tile {map_tile_descriptor:?} requested");
742 let url = Self::map_tile_url(map_tile_descriptor);
743 let request = self.client.get(&url).build()?;
744 let now = std::time::SystemTime::now();
745 if let Some((cached_map_tile, cache_policy)) =
746 self.fetch_cached_map_tile(map_tile_descriptor).await?
747 {
748 if cached_map_tile.is_some() {
749 tracing::debug!("Found matching map tile in cache, checking freshness");
750 } else {
751 tracing::debug!("Found matching map tile absence in cache, checking freshness");
752 }
753 if let http_cache_semantics::BeforeRequest::Fresh(_) =
754 cache_policy.before_request(&request, now)
755 {
756 if cached_map_tile.is_some() {
757 tracing::debug!("Using cached map tile");
758 } else {
759 tracing::debug!("Using cached map tile absence");
760 }
761 return Ok(cached_map_tile);
762 }
763 tracing::debug!("Map tile cache not fresh, removing from cache");
764 self.remove_cached_tile(map_tile_descriptor).await?;
765 }
766 tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
767 if let Some(ratelimiter) = &self.ratelimiter {
768 while let Err(duration) = ratelimiter.try_wait() {
769 tokio::time::sleep(duration).await;
770 }
771 }
772 tracing::debug!("Fetching map tile from server at {}", url);
773 let response = self
774 .client
775 .execute(
776 request
777 .try_clone()
778 .ok_or(MapTileCacheError::FailedToCloneRequest)?,
779 )
780 .await?;
781 tracing::debug!(
782 "Server response received: status {}, headers\n{:#?}",
783 response.status(),
784 response.headers()
785 );
786 if !response.status().is_success() {
787 if response.status() == reqwest::StatusCode::FORBIDDEN {
788 tracing::debug!(
791 "Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
792 );
793 let cache_policy = http_cache_semantics::CachePolicy::new(
794 &request,
795 &MapTileNegativeResponse(response),
796 );
797 self.cache_missing_tile(map_tile_descriptor, cache_policy)
798 .await?;
799 return Ok(None);
800 }
801 return Err(MapTileCacheError::HttpError(
802 url.to_owned(),
803 response.status(),
804 response.headers().to_owned(),
805 response.text().await?,
806 ));
807 }
808 let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
809 let raw_response_body = response.bytes().await?;
810 tracing::debug!("Parsing received map tile to image");
811 let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
812 .with_guessed_format()
813 .map_err(MapTileCacheError::ImageFormatGuessError)?
814 .decode()?;
815 let map_tile = MapTile {
816 descriptor: map_tile_descriptor.to_owned(),
817 image,
818 };
819 self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
820 .await?;
821 tracing::debug!("Returning freshly fetched map tile");
822 Ok(Some(map_tile))
823 }
824
825 pub async fn does_map_tile_exist(
832 &mut self,
833 map_tile_descriptor: &MapTileDescriptor,
834 ) -> Result<bool, MapTileCacheError> {
835 let url = Self::map_tile_url(map_tile_descriptor);
836 if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
837 let request = self.client.get(&url).build()?;
838 let now = std::time::SystemTime::now();
839 if let http_cache_semantics::BeforeRequest::Fresh(_) =
840 cache_policy.before_request(&request, now)
841 {
842 return Ok(map_tile.is_some());
843 }
844 }
845 if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
846 && let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
847 {
848 let request = self.client.get(&url).build()?;
849 let now = std::time::SystemTime::now();
850 if let http_cache_semantics::BeforeRequest::Fresh(_) =
851 cache_policy.before_request(&request, now)
852 {
853 if self
854 .map_tile_cache_negative_response_file_name(map_tile_descriptor)
855 .exists()
856 {
857 return Ok(false);
858 }
859 return Ok(true);
860 }
861 }
862 Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
863 }
864
865 pub async fn does_region_exist(
872 &mut self,
873 grid_coordinates: &GridCoordinates,
874 ) -> Result<bool, MapTileCacheError> {
875 for zoom_level in (1..=8).rev() {
876 tracing::debug!(
877 "Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
878 );
879 let map_tile_descriptor = MapTileDescriptor::new(
880 ZoomLevel::try_new(zoom_level)?,
881 grid_coordinates.to_owned(),
882 );
883 if !self.does_map_tile_exist(&map_tile_descriptor).await? {
884 tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
885 return Ok(false);
886 }
887 let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
888 if cache_entry_status == MapTileCacheEntryStatus::Valid {}
889 }
890 tracing::debug!(
891 "Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
892 );
893 Ok(true)
894 }
895}
896
897#[derive(Debug, Clone)]
899pub struct Map {
900 zoom_level: ZoomLevel,
902 grid_rectangle: GridRectangle,
904 image: image::DynamicImage,
906}
907
908#[derive(Debug, thiserror::Error)]
910pub enum MapError {
911 #[error("error in map tile cache while assembling map: {0}")]
913 MapTileCacheError(#[from] MapTileCacheError),
914 #[error(
917 "error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
918 )]
919 ZoomFitError(#[from] ZoomFitError),
920 #[error("error when cropping a map tile to the required size")]
922 MapTileCropError,
923 #[error("error when calculating pixel coordinates where we want to place a map tile crop")]
925 MapCoordinateError,
926 #[error("no overlap between map tile we fetched and output map (should not happen)")]
928 NoOverlapError,
929 #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
932 NoGridCoordinatesForRegion(RegionName),
933 #[error("error in region name to grid coordinate cache: {0}")]
935 RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
936 #[error("error calculating spline: {0}")]
938 SplineError(
939 #[source]
940 #[from]
941 uniform_cubic_splines::SplineError,
942 ),
943}
944
945impl Map {
946 pub async fn new(
966 map_tile_cache: &mut MapTileCache,
967 x: u32,
968 y: u32,
969 grid_rectangle: GridRectangle,
970 fill_missing_map_tiles: Option<image::Rgba<u8>>,
971 fill_missing_regions: Option<image::Rgba<u8>>,
972 ) -> Result<Self, MapError> {
973 let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
974 grid_rectangle.size_x(),
975 grid_rectangle.size_y(),
976 x,
977 y,
978 )?;
979 let actual_x = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
980 * <u16 as Into<u32>>::into(grid_rectangle.size_x());
981 let actual_y = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
982 * <u16 as Into<u32>>::into(grid_rectangle.size_y());
983 tracing::debug!(
984 "Determined max zoom level for map of size ({x}, {y}) for {grid_rectangle:?} to be {zoom_level:?}, actual map size will be ({actual_x}, {actual_y})"
985 );
986 let x = actual_x;
987 let y = actual_y;
988 let image = image::DynamicImage::new_rgb8(x, y);
989 let mut result = Self {
990 zoom_level,
991 grid_rectangle,
992 image,
993 };
994 for region_x in result.x_range() {
995 for region_y in result.y_range() {
996 let grid_coordinates = GridCoordinates::new(region_x, region_y);
997 let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
998 let Some(overlap) = result.intersect(&map_tile_descriptor) else {
999 return Err(MapError::NoOverlapError);
1000 };
1001 if overlap.lower_left_corner().x() != region_x
1002 || overlap.lower_left_corner().y() != region_y
1003 {
1004 continue;
1007 }
1008 tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1009 if let Some(map_tile) = map_tile_cache.get_map_tile(&map_tile_descriptor).await? {
1010 let crop = map_tile
1011 .crop_imm_grid_rectangle(&overlap)
1012 .ok_or(MapError::MapTileCropError)?;
1013 tracing::debug!(
1014 "Cropped map tile to ({}, {})+{}x{}",
1015 crop.offsets().0,
1016 crop.offsets().1,
1017 (*crop).dimensions().0,
1018 (*crop).dimensions().1
1019 );
1020 let (replace_x, replace_y) = result
1023 .pixel_coordinates_for_coordinates(
1024 &overlap.upper_left_corner(),
1025 &RegionCoordinates::new(0f32, 256f32, 0f32),
1026 )
1027 .ok_or(MapError::MapCoordinateError)?;
1028 tracing::debug!(
1029 "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1030 );
1031 image::imageops::replace(
1032 &mut result,
1033 &*crop,
1034 replace_x.into(),
1035 replace_y.into(),
1036 );
1037 if let Some(fill_color) = fill_missing_regions {
1038 for overlap_region_x in overlap.x_range() {
1039 for overlap_region_y in overlap.y_range() {
1040 let grid_coordinates =
1041 GridCoordinates::new(overlap_region_x, overlap_region_y);
1042 if !map_tile_cache.does_region_exist(&grid_coordinates).await? {
1043 let pixel_min = result.pixel_coordinates_for_coordinates(
1044 &grid_coordinates,
1045 &RegionCoordinates::new(0f32, 256f32, 0f32),
1046 );
1047 let pixel_max = result.pixel_coordinates_for_coordinates(
1048 &grid_coordinates,
1049 &RegionCoordinates::new(256f32, 0f32, 0f32),
1050 );
1051 if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1052 (pixel_min, pixel_max)
1053 {
1054 for x in min_x..max_x {
1055 for y in min_y..max_y {
1056 <Self as image::GenericImage>::put_pixel(
1057 &mut result,
1058 x,
1059 y,
1060 fill_color,
1061 );
1062 }
1063 }
1064 }
1065 }
1066 }
1067 }
1068 }
1069 } else if let Some(fill_color) = fill_missing_map_tiles {
1070 let (replace_x, replace_y) = result
1071 .pixel_coordinates_for_coordinates(
1072 &overlap.upper_left_corner(),
1073 &RegionCoordinates::new(0f32, 256f32, 0f32),
1074 )
1075 .ok_or(MapError::MapCoordinateError)?;
1076 let pixel_size_x =
1077 u32::from(overlap.size_x()) * u32::from(zoom_level.pixels_per_region());
1078 let pixel_size_y =
1079 u32::from(overlap.size_y()) * u32::from(zoom_level.pixels_per_region());
1080 for x in replace_x..replace_x + pixel_size_x {
1081 for y in replace_y..replace_y + pixel_size_y {
1082 <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1083 }
1084 }
1085 }
1086 }
1087 }
1088 Ok(result)
1089 }
1090
1091 pub async fn draw_route(
1098 &mut self,
1099 region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1100 usb_notecard: &USBNotecard,
1101 color: image::Rgba<u8>,
1102 ) -> Result<(), MapError> {
1103 tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1104 let mut pixel_waypoints = Vec::new();
1105 for waypoint in usb_notecard.waypoints() {
1106 let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1107 .get_grid_coordinates(waypoint.location().region_name())
1108 .await?
1109 else {
1110 return Err(MapError::NoGridCoordinatesForRegion(
1111 waypoint.location().region_name().to_owned(),
1112 ));
1113 };
1114 let (x, y) = self
1115 .pixel_coordinates_for_coordinates(
1116 &grid_coordinates,
1117 &waypoint.region_coordinates(),
1118 )
1119 .ok_or(MapError::MapCoordinateError)?;
1120 tracing::debug!(
1121 "Drawing waypoint at ({x}, {y}) for location {:?}",
1122 waypoint.location()
1123 );
1124 #[expect(
1126 clippy::cast_precision_loss,
1127 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1128 )]
1129 pixel_waypoints.push((x as f32, y as f32));
1130 }
1131 let waypoint_count = pixel_waypoints.len();
1132 let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1133 return Ok(());
1135 };
1136 let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1137 else {
1138 return Ok(());
1140 };
1141 let extra_before_start = (
1142 first.0 - (second.0 - first.0),
1143 first.1 - (second.1 - first.1),
1144 );
1145 let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1146 return Ok(());
1148 };
1149 let Some((second_to_last, _pixel_waypoints_rest)) =
1150 pixel_waypoints_all_but_last.split_last()
1151 else {
1152 return Ok(());
1154 };
1155 let extra_after_end = (
1156 last.0 + (last.0 - second_to_last.0),
1157 last.1 + (last.1 - second_to_last.1),
1158 );
1159 let mut knots = vec![extra_before_start];
1160 knots.extend(pixel_waypoints.to_owned());
1161 knots.push(extra_after_end);
1162 let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1163 let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1164 let point_x =
1165 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1166 v, &points_x,
1167 )?;
1168 let point_y =
1169 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1170 v, &points_y,
1171 )?;
1172 Ok((point_x, point_y))
1173 };
1174 #[expect(
1175 clippy::cast_precision_loss,
1176 reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1177 )]
1178 let spline_value_for_waypoint =
1179 |i: usize| -> f32 { i as f32 / (waypoint_count as f32 - 2f32) };
1180 let spline_value_between_waypoints = spline_value_for_waypoint(1);
1181 let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1182 ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1183 };
1184 let mut last_point: Option<(f32, f32)> = None;
1185 for (i, waypoint) in pixel_waypoints.iter().enumerate().take(waypoint_count - 1) {
1186 const SPLINE_RECT_SIZE: u8 = 3;
1189 tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1190 let v = spline_value_for_waypoint(i);
1191 let point = sample(v)?;
1192 tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1193 if let Some(last_point) = last_point {
1194 let distance_from_last_point = distance_between_points(point, last_point);
1195 tracing::debug!(
1196 "Waypoint {i} is {:?} from last waypoint",
1197 distance_from_last_point
1198 );
1199 #[expect(
1200 clippy::cast_possible_truncation,
1201 reason = "we want an integer count for the number of samples"
1202 )]
1203 #[expect(
1204 clippy::cast_sign_loss,
1205 reason = "we want a positive count for the number of samples"
1206 )]
1207 let samples_between_last_waypoint_and_this_one =
1208 (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1209 for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1210 #[expect(
1211 clippy::cast_precision_loss,
1212 reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1213 )]
1214 let v = v - spline_value_between_waypoints
1215 * (j as f32 / (samples_between_last_waypoint_and_this_one as f32 - 2f32));
1216 let sample_point = sample(v)?;
1217 #[expect(
1218 clippy::cast_possible_truncation,
1219 reason = "we want integer pixel coordinates for use in the image library"
1220 )]
1221 imageproc::drawing::draw_filled_rect_mut(
1222 self.image_mut(),
1223 imageproc::rect::Rect::at(
1224 sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1225 sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1226 )
1227 .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1228 color,
1229 );
1230 }
1231 self.draw_arrow(
1232 sample(v - (0.1f32 * spline_value_between_waypoints))?,
1233 point,
1234 color,
1235 );
1236 }
1237 last_point = Some(point);
1238 }
1239 Ok(())
1240 }
1241
1242 pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1249 self.image.save(path)
1250 }
1251}
1252
1253impl GridRectangleLike for Map {
1254 fn grid_rectangle(&self) -> GridRectangle {
1255 self.grid_rectangle.to_owned()
1256 }
1257}
1258
1259impl image::GenericImageView for Map {
1260 type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1261
1262 fn dimensions(&self) -> (u32, u32) {
1263 self.image.dimensions()
1264 }
1265
1266 fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1267 self.image.get_pixel(x, y)
1268 }
1269}
1270
1271impl image::GenericImage for Map {
1272 fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1273 #[expect(
1274 deprecated,
1275 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1276 )]
1277 self.image.get_pixel_mut(x, y)
1278 }
1279
1280 fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1281 self.image.put_pixel(x, y, pixel);
1282 }
1283
1284 fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1285 #[expect(
1286 deprecated,
1287 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1288 )]
1289 self.image.blend_pixel(x, y, pixel);
1290 }
1291}
1292
1293impl MapLike for Map {
1294 fn zoom_level(&self) -> ZoomLevel {
1295 self.zoom_level
1296 }
1297
1298 fn image(&self) -> &image::DynamicImage {
1299 &self.image
1300 }
1301
1302 fn image_mut(&mut self) -> &mut image::DynamicImage {
1303 &mut self.image
1304 }
1305}
1306
1307#[cfg(test)]
1308mod test {
1309 use image::GenericImageView as _;
1310 use tracing_test::traced_test;
1311
1312 use super::*;
1313
1314 #[tokio::test]
1315 async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1316 let temp_dir = tempfile::tempdir()?;
1317 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1318 map_tile_cache
1319 .get_map_tile(&MapTileDescriptor::new(
1320 ZoomLevel::try_new(1)?,
1321 GridCoordinates::new(1136, 1075),
1322 ))
1323 .await?;
1324 Ok(())
1325 }
1326
1327 #[tokio::test]
1328 async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1329 let temp_dir = tempfile::tempdir()?;
1330 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1331 map_tile_cache
1332 .get_map_tile(&MapTileDescriptor::new(
1333 ZoomLevel::try_new(1)?,
1334 GridCoordinates::new(1136, 1075),
1335 ))
1336 .await?;
1337 map_tile_cache
1338 .get_map_tile(&MapTileDescriptor::new(
1339 ZoomLevel::try_new(1)?,
1340 GridCoordinates::new(1136, 1075),
1341 ))
1342 .await?;
1343 Ok(())
1344 }
1345
1346 #[tokio::test]
1347 async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1348 let temp_dir = tempfile::tempdir()?;
1349 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1350 map_tile_cache
1351 .get_map_tile(&MapTileDescriptor::new(
1352 ZoomLevel::try_new(8)?,
1353 GridCoordinates::new(1136, 1075),
1354 ))
1355 .await?;
1356 Ok(())
1357 }
1358
1359 #[traced_test]
1360 #[tokio::test]
1361 async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1362 let temp_dir = tempfile::tempdir()?;
1363 let ratelimiter =
1364 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1365 let mut map_tile_cache =
1366 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1367 let map = Map::new(
1368 &mut map_tile_cache,
1369 512,
1370 512,
1371 GridRectangle::new(
1372 GridCoordinates::new(1135, 1070),
1373 GridCoordinates::new(1136, 1071),
1374 ),
1375 None,
1376 None,
1377 )
1378 .await?;
1379 map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1380 Ok(())
1381 }
1382
1383 #[traced_test]
1384 #[tokio::test]
1385 async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1386 let temp_dir = tempfile::tempdir()?;
1387 let ratelimiter =
1388 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1389 let mut map_tile_cache =
1390 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1391 let map = Map::new(
1392 &mut map_tile_cache,
1393 256,
1394 256,
1395 GridRectangle::new(
1396 GridCoordinates::new(1136, 1074),
1397 GridCoordinates::new(1137, 1075),
1398 ),
1399 None,
1400 None,
1401 )
1402 .await?;
1403 map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1404 Ok(())
1405 }
1406
1407 #[traced_test]
1408 #[tokio::test]
1409 async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1410 let temp_dir = tempfile::tempdir()?;
1411 let ratelimiter =
1412 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1413 let mut map_tile_cache =
1414 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1415 let map = Map::new(
1416 &mut map_tile_cache,
1417 128,
1418 128,
1419 GridRectangle::new(
1420 GridCoordinates::new(1136, 1074),
1421 GridCoordinates::new(1137, 1075),
1422 ),
1423 None,
1424 None,
1425 )
1426 .await?;
1427 map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1428 Ok(())
1429 }
1430
1431 #[traced_test]
1432 #[tokio::test]
1433 async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1434 let temp_dir = tempfile::tempdir()?;
1435 let ratelimiter =
1436 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_millis(100)).build()?;
1437 let mut map_tile_cache =
1438 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1439 let map = Map::new(
1440 &mut map_tile_cache,
1441 2048,
1442 2048,
1443 GridRectangle::new(
1444 GridCoordinates::new(1131, 1068),
1445 GridCoordinates::new(1139, 1075),
1446 ),
1447 None,
1448 None,
1449 )
1450 .await?;
1451 map.save(std::path::Path::new(
1452 "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1453 ))?;
1454 Ok(())
1455 }
1456
1457 #[traced_test]
1458 #[tokio::test]
1459 #[expect(clippy::panic, reason = "panic in test is intentional")]
1460 async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1461 -> Result<(), Box<dyn std::error::Error>> {
1462 let temp_dir = tempfile::tempdir()?;
1463 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1464 let Some(map_tile) = map_tile_cache
1465 .get_map_tile(&MapTileDescriptor::new(
1466 ZoomLevel::try_new(1)?,
1467 GridCoordinates::new(1136, 1075),
1468 ))
1469 .await?
1470 else {
1471 panic!("Expected there to be a region at this location");
1472 };
1473 for in_region_x in 0..=256 {
1474 for in_region_y in 0..=256 {
1475 let grid_coordinates = GridCoordinates::new(1136, 1075);
1476 #[expect(
1477 clippy::cast_precision_loss,
1478 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1479 )]
1480 let region_coordinates =
1481 RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
1482 tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
1483 assert_eq!(
1484 map_tile
1485 .pixel_coordinates_for_coordinates(&grid_coordinates, ®ion_coordinates,),
1486 Some((in_region_x, 256 - in_region_y)),
1487 );
1488 }
1489 }
1490 Ok(())
1491 }
1492
1493 #[traced_test]
1494 #[tokio::test]
1495 async fn test_map_pixel_coordinates_for_coordinates_four_regions()
1496 -> Result<(), Box<dyn std::error::Error>> {
1497 let temp_dir = tempfile::tempdir()?;
1498 let ratelimiter =
1499 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1500 let mut map_tile_cache =
1501 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1502 let map = Map::new(
1503 &mut map_tile_cache,
1504 512,
1505 512,
1506 GridRectangle::new(
1507 GridCoordinates::new(1136, 1074),
1508 GridCoordinates::new(1137, 1075),
1509 ),
1510 None,
1511 None,
1512 )
1513 .await?;
1514 for region_offset_x in 0..=1 {
1515 for region_offset_y in 0..=1 {
1516 for in_region_x in 0..=256 {
1517 for in_region_y in 0..=256 {
1518 let grid_coordinates =
1519 GridCoordinates::new(1136 + region_offset_x, 1074 + region_offset_y);
1520 let region_coordinates = RegionCoordinates::new(
1521 f32::from(in_region_x),
1522 f32::from(in_region_y),
1523 0f32,
1524 );
1525 tracing::debug!(
1526 "Now checking {grid_coordinates:?}, {region_coordinates:?}"
1527 );
1528 assert_eq!(
1529 map.pixel_coordinates_for_coordinates(
1530 &grid_coordinates,
1531 ®ion_coordinates,
1532 ),
1533 Some((
1534 u32::from(region_offset_x * 256 + in_region_x),
1535 u32::from(512 - (region_offset_y * 256 + in_region_y))
1536 )),
1537 );
1538 }
1539 }
1540 }
1541 }
1542 Ok(())
1543 }
1544
1545 #[traced_test]
1546 #[tokio::test]
1547 #[expect(clippy::panic, reason = "panic in test is intentional")]
1548 async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
1549 -> Result<(), Box<dyn std::error::Error>> {
1550 let temp_dir = tempfile::tempdir()?;
1551 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1552 let Some(map_tile) = map_tile_cache
1553 .get_map_tile(&MapTileDescriptor::new(
1554 ZoomLevel::try_new(1)?,
1555 GridCoordinates::new(1136, 1075),
1556 ))
1557 .await?
1558 else {
1559 panic!("Expected there to be a region at this location");
1560 };
1561 tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
1562 #[expect(
1563 clippy::cast_precision_loss,
1564 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1565 )]
1566 for in_region_x in 0..=256 {
1567 for in_region_y in 0..=256 {
1568 let pixel_x = in_region_x;
1569 let pixel_y = 256 - in_region_y;
1570 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
1571 assert_eq!(
1572 map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
1573 Some((
1574 GridCoordinates::new(
1575 1136 + if in_region_x == 256 { 1 } else { 0 },
1576 1075 + if in_region_y == 256 { 1 } else { 0 }
1577 ),
1578 RegionCoordinates::new(
1579 (in_region_x % 256) as f32,
1580 (in_region_y % 256) as f32,
1581 0f32
1582 ),
1583 ))
1584 );
1585 }
1586 }
1587 Ok(())
1588 }
1589
1590 #[traced_test]
1591 #[tokio::test]
1592 async fn test_map_coordinates_for_pixel_coordinates_four_regions()
1593 -> Result<(), Box<dyn std::error::Error>> {
1594 let temp_dir = tempfile::tempdir()?;
1595 let ratelimiter =
1596 ratelimit::Ratelimiter::builder(1, std::time::Duration::from_secs(1)).build()?;
1597 let mut map_tile_cache =
1598 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1599 let map = Map::new(
1600 &mut map_tile_cache,
1601 512,
1602 512,
1603 GridRectangle::new(
1604 GridCoordinates::new(1136, 1074),
1605 GridCoordinates::new(1137, 1075),
1606 ),
1607 None,
1608 None,
1609 )
1610 .await?;
1611 tracing::debug!("Dimensions of map are {:?}", map.dimensions());
1612 for region_offset_x in 0..=1 {
1613 for region_offset_y in 0..=1 {
1614 for in_region_x in 0..=256 {
1615 for in_region_y in 0..=256 {
1616 let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
1617 let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
1618 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
1619 assert_eq!(
1620 map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
1621 Some((
1622 GridCoordinates::new(
1623 1136 + region_offset_x + if in_region_x == 256 { 1 } else { 0 },
1624 1074 + region_offset_y + if in_region_y == 256 { 1 } else { 0 }
1625 ),
1626 RegionCoordinates::new(
1627 f32::from(in_region_x % 256),
1628 f32::from(in_region_y % 256),
1629 0f32
1630 ),
1631 )),
1632 );
1633 }
1634 }
1635 }
1636 }
1637 Ok(())
1638 }
1639}