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_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 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 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 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 fn draw_arrow(&mut self, from: (f32, f32), tip: (f32, f32), color: image::Rgba<u8>) {
309 const ARROW_LENGTH: f32 = 15f32;
311 const ARROW_HALF_WIDTH: f32 = 5f32;
313 if from == tip {
314 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum TileOutcome {
378 LoadedFromMemoryCache,
380 LoadedFromDiskCache,
382 FetchedFromNetwork,
384 Missing,
387}
388
389#[derive(Debug, Clone)]
391pub enum MapProgressEvent {
392 PlanComputed {
395 zoom_level: ZoomLevel,
397 total_tiles: u32,
399 },
400 TileStarted {
402 descriptor: MapTileDescriptor,
404 },
405 TileFinished {
407 descriptor: MapTileDescriptor,
409 outcome: TileOutcome,
411 },
412 RegionCheckPlanned {
417 total_regions: u32,
422 },
423 RegionChecked {
425 x: u16,
427 y: u16,
429 exists: bool,
431 },
432 RoutePlanned {
435 total_waypoints: usize,
437 },
438 RouteWaypointResolved {
440 index: usize,
442 total: usize,
444 region: RegionName,
446 },
447 RegionNamesPlanned {
450 total_regions: u32,
452 },
453 RegionNameResolved {
455 index: u32,
457 total: u32,
459 },
460}
461
462fn emit_progress(
466 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
467 event: MapProgressEvent,
468) {
469 if let Some(sender) = progress {
470 drop(sender.try_send(event));
472 }
473}
474
475#[derive(Debug, Clone)]
477pub struct MapTile {
478 descriptor: MapTileDescriptor,
480
481 image: image::DynamicImage,
483}
484
485impl MapTile {
486 #[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#[derive(Debug, thiserror::Error)]
549pub enum MapTileCacheError {
550 #[error("error manipulating files in the cache directory: {0}")]
552 CacheDirectoryFileError(std::io::Error),
553 #[error("reqwest error when fetching the map tile from the server: {0}")]
555 ReqwestError(#[from] reqwest::Error),
556 #[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 #[error("failed to clone request for cache policy")]
567 FailedToCloneRequest,
568 #[error("ratelimiter rejected request: {0:?}")]
571 RatelimiterError(ratelimit::TryWaitError),
572 #[error("error guessing image format: {0}")]
574 ImageFormatGuessError(std::io::Error),
575 #[error("error reading the raw map tile into an image: {0}")]
577 ImageError(#[from] image::ImageError),
578 #[error("error decoding the JSON serialized CachePolicy: {0}")]
580 CachePolicyJsonDecodeError(#[from] serde_json::Error),
581 #[error("error creating a zoom level: {0}")]
583 ZoomLevelError(#[from] ZoomLevelError),
584 #[error("error when trying to load cache policy that we previously checked existed on disk")]
587 CachePolicyError,
588}
589
590#[derive(derive_more::Debug)]
592pub struct MapTileCache {
593 client: reqwest::Client,
595 #[debug(skip)]
597 ratelimiter: Option<ratelimit::Ratelimiter>,
598 cache_directory: PathBuf,
600 #[debug(skip)]
602 cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq)]
607pub enum MapTileCacheEntryStatus {
608 Missing,
610 Invalid,
612 Valid,
614}
615
616#[derive(Debug)]
619pub struct MapTileNegativeResponse(reqwest::Response);
620
621impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
622 fn status(&self) -> http::status::StatusCode {
623 match self.0.status() {
624 http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
625 status => status,
626 }
627 }
628
629 fn headers(&self) -> &http::header::HeaderMap {
630 self.0.headers()
631 }
632}
633
634impl MapTileCache {
635 #[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
637 #[must_use]
638 pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
639 #[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
640 let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
641 Self {
642 client: reqwest::Client::new(),
643 ratelimiter,
644 cache_directory,
645 cache,
646 }
647 }
648
649 #[must_use]
651 fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
652 format!(
653 "map-{}-{}-{}-objects.jpg",
654 map_tile_descriptor.zoom_level(),
655 map_tile_descriptor.lower_left_corner().x(),
656 map_tile_descriptor.lower_left_corner().y(),
657 )
658 }
659
660 #[must_use]
662 fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
663 self.cache_directory
664 .join(Self::map_tile_file_name(map_tile_descriptor))
665 }
666
667 #[must_use]
669 fn map_tile_cache_negative_response_file_name(
670 &self,
671 map_tile_descriptor: &MapTileDescriptor,
672 ) -> PathBuf {
673 self.cache_directory.join(format!(
674 "{}.does-not-exist",
675 Self::map_tile_file_name(map_tile_descriptor)
676 ))
677 }
678
679 #[must_use]
681 fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
682 self.cache_directory.join(format!(
683 "{}.cache-policy.json",
684 Self::map_tile_file_name(map_tile_descriptor)
685 ))
686 }
687
688 #[must_use]
690 fn map_tile_url(map_tile_descriptor: &MapTileDescriptor) -> String {
691 format!(
692 "https://secondlife-maps-cdn.akamaized.net/{}",
693 Self::map_tile_file_name(map_tile_descriptor),
694 )
695 }
696
697 async fn cache_entry_status(
699 &self,
700 map_tile_descriptor: &MapTileDescriptor,
701 ) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
702 match (
703 self.cache_policy_file_name(map_tile_descriptor).exists(),
704 self.map_tile_cache_file_name(map_tile_descriptor).exists(),
705 self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
706 .exists(),
707 ) {
708 (false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
709 (true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
710 (cp, tile, neg) => {
711 tracing::warn!(
712 "cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
713 cp,
714 tile,
715 neg
716 );
717 Ok(MapTileCacheEntryStatus::Invalid)
718 }
719 }
720 }
721
722 async fn fetch_cached_map_tile(
729 &mut self,
730 map_tile_descriptor: &MapTileDescriptor,
731 ) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
732 {
733 if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
734 return Ok(Some(cache_entry.to_owned()));
735 }
736 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
737 let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
738 if cache_entry_status == MapTileCacheEntryStatus::Invalid {
739 self.remove_cached_tile(map_tile_descriptor).await?;
740 return Ok(None);
741 }
742 if cache_entry_status == MapTileCacheEntryStatus::Missing {
743 return Ok(None);
744 }
745 let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
746 return Err(MapTileCacheError::CachePolicyError);
747 };
748 if cache_file.exists() {
749 let cached_map_tile = image::ImageReader::open(cache_file)
750 .map_err(MapTileCacheError::CacheDirectoryFileError)?
751 .decode()?;
752 Ok(Some((
753 Some(MapTile {
754 descriptor: map_tile_descriptor.to_owned(),
755 image: cached_map_tile,
756 }),
757 cache_policy,
758 )))
759 } else {
760 Ok(Some((None, cache_policy)))
762 }
763 }
764
765 async fn remove_cached_tile(
767 &mut self,
768 map_tile_descriptor: &MapTileDescriptor,
769 ) -> Result<(), MapTileCacheError> {
770 tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
771 self.cache.pop(map_tile_descriptor);
772 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
773 let cache_file_negative_response =
774 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
775 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
776 if cache_file.exists() {
777 std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
778 }
779 if cache_file_negative_response.exists() {
780 std::fs::remove_file(cache_file_negative_response)
781 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
782 }
783 if cache_policy_file.exists() {
784 std::fs::remove_file(cache_policy_file)
785 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
786 }
787 Ok(())
788 }
789
790 async fn load_cache_policy(
797 &self,
798 map_tile_descriptor: &MapTileDescriptor,
799 ) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
800 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
801 if !cache_policy_file.exists() {
802 return Ok(None);
803 }
804 let cache_policy = std::fs::read_to_string(cache_policy_file)
805 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
806 Ok(serde_json::from_str(&cache_policy)?)
807 }
808
809 async fn store_cache_policy(
816 &self,
817 map_tile_descriptor: &MapTileDescriptor,
818 cache_policy: http_cache_semantics::CachePolicy,
819 ) -> Result<(), MapTileCacheError> {
820 if !self.cache_directory.exists() {
821 std::fs::create_dir_all(&self.cache_directory)
822 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
823 }
824 let cache_policy = serde_json::to_string(&cache_policy)?;
825 std::fs::write(
826 self.cache_policy_file_name(map_tile_descriptor),
827 cache_policy,
828 )
829 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
830 Ok(())
831 }
832
833 async fn cache_missing_tile(
841 &mut self,
842 map_tile_descriptor: &MapTileDescriptor,
843 cache_policy: http_cache_semantics::CachePolicy,
844 ) -> Result<(), MapTileCacheError> {
845 if cache_policy.is_storable() {
846 tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
847 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
848 .await?;
849 let cache_file_negative_response =
850 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
851 std::fs::File::create(cache_file_negative_response)
852 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
853 self.cache
854 .put(map_tile_descriptor.clone(), (None, cache_policy));
855 } else {
856 tracing::warn!(
857 "Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
858 );
859 }
860 Ok(())
861 }
862
863 async fn cache_tile(
871 &mut self,
872 map_tile_descriptor: &MapTileDescriptor,
873 map_tile: &MapTile,
874 cache_policy: http_cache_semantics::CachePolicy,
875 ) -> Result<(), MapTileCacheError> {
876 if cache_policy.is_storable() {
877 tracing::debug!("Caching map tile {map_tile_descriptor:?}");
878 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
879 .await?;
880 map_tile
881 .image
882 .save(self.map_tile_cache_file_name(map_tile_descriptor))?;
883 self.cache.put(
884 map_tile_descriptor.clone(),
885 (Some(map_tile.to_owned()), cache_policy),
886 );
887 } else {
888 tracing::warn!(
889 "Map tile {map_tile_descriptor:?} not storable according to cache policy"
890 );
891 }
892 Ok(())
893 }
894
895 pub async fn get_map_tile(
903 &mut self,
904 map_tile_descriptor: &MapTileDescriptor,
905 ) -> Result<Option<MapTile>, MapTileCacheError> {
906 Ok(self.get_map_tile_with_outcome(map_tile_descriptor).await?.0)
907 }
908
909 pub async fn get_map_tile_with_outcome(
919 &mut self,
920 map_tile_descriptor: &MapTileDescriptor,
921 ) -> Result<(Option<MapTile>, TileOutcome), MapTileCacheError> {
922 tracing::debug!("Map tile {map_tile_descriptor:?} requested");
923 let url = Self::map_tile_url(map_tile_descriptor);
924 let request = self.client.get(&url).build()?;
925 let now = std::time::SystemTime::now();
926 let memory_cache_hit = self.cache.peek(map_tile_descriptor).is_some();
929 if let Some((cached_map_tile, cache_policy)) =
930 self.fetch_cached_map_tile(map_tile_descriptor).await?
931 {
932 if cached_map_tile.is_some() {
933 tracing::debug!("Found matching map tile in cache, checking freshness");
934 } else {
935 tracing::debug!("Found matching map tile absence in cache, checking freshness");
936 }
937 if let http_cache_semantics::BeforeRequest::Fresh(_) =
938 cache_policy.before_request(&request, now)
939 {
940 if cached_map_tile.is_some() {
941 tracing::debug!("Using cached map tile");
942 } else {
943 tracing::debug!("Using cached map tile absence");
944 }
945 let outcome = if cached_map_tile.is_none() {
946 TileOutcome::Missing
947 } else if memory_cache_hit {
948 TileOutcome::LoadedFromMemoryCache
949 } else {
950 TileOutcome::LoadedFromDiskCache
951 };
952 return Ok((cached_map_tile, outcome));
953 }
954 tracing::debug!("Map tile cache not fresh, removing from cache");
955 self.remove_cached_tile(map_tile_descriptor).await?;
956 }
957 tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
958 if let Some(ratelimiter) = &self.ratelimiter {
959 while let Err(err) = ratelimiter.try_wait() {
960 match err {
961 ratelimit::TryWaitError::Insufficient(duration) => {
962 tokio::time::sleep(duration).await;
963 }
964 _ => {
965 return Err(MapTileCacheError::RatelimiterError(err));
966 }
967 }
968 }
969 }
970 tracing::debug!("Fetching map tile from server at {}", url);
971 let response = self
972 .client
973 .execute(
974 request
975 .try_clone()
976 .ok_or(MapTileCacheError::FailedToCloneRequest)?,
977 )
978 .await?;
979 tracing::debug!(
980 "Server response received: status {}, headers\n{:#?}",
981 response.status(),
982 response.headers()
983 );
984 if !response.status().is_success() {
985 if response.status() == reqwest::StatusCode::FORBIDDEN {
986 tracing::debug!(
989 "Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
990 );
991 let cache_policy = http_cache_semantics::CachePolicy::new(
992 &request,
993 &MapTileNegativeResponse(response),
994 );
995 self.cache_missing_tile(map_tile_descriptor, cache_policy)
996 .await?;
997 return Ok((None, TileOutcome::Missing));
998 }
999 return Err(MapTileCacheError::HttpError(
1000 url.to_owned(),
1001 response.status(),
1002 response.headers().to_owned(),
1003 response.text().await?,
1004 ));
1005 }
1006 let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
1007 let raw_response_body = response.bytes().await?;
1008 tracing::debug!("Parsing received map tile to image");
1009 let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
1010 .with_guessed_format()
1011 .map_err(MapTileCacheError::ImageFormatGuessError)?
1012 .decode()?;
1013 let map_tile = MapTile {
1014 descriptor: map_tile_descriptor.to_owned(),
1015 image,
1016 };
1017 self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
1018 .await?;
1019 tracing::debug!("Returning freshly fetched map tile");
1020 Ok((Some(map_tile), TileOutcome::FetchedFromNetwork))
1021 }
1022
1023 pub async fn does_map_tile_exist(
1030 &mut self,
1031 map_tile_descriptor: &MapTileDescriptor,
1032 ) -> Result<bool, MapTileCacheError> {
1033 let url = Self::map_tile_url(map_tile_descriptor);
1034 if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
1035 let request = self.client.get(&url).build()?;
1036 let now = std::time::SystemTime::now();
1037 if let http_cache_semantics::BeforeRequest::Fresh(_) =
1038 cache_policy.before_request(&request, now)
1039 {
1040 return Ok(map_tile.is_some());
1041 }
1042 }
1043 if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
1044 && let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
1045 {
1046 let request = self.client.get(&url).build()?;
1047 let now = std::time::SystemTime::now();
1048 if let http_cache_semantics::BeforeRequest::Fresh(_) =
1049 cache_policy.before_request(&request, now)
1050 {
1051 if self
1052 .map_tile_cache_negative_response_file_name(map_tile_descriptor)
1053 .exists()
1054 {
1055 return Ok(false);
1056 }
1057 return Ok(true);
1058 }
1059 }
1060 Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
1061 }
1062
1063 pub async fn does_region_exist(
1070 &mut self,
1071 grid_coordinates: &GridCoordinates,
1072 ) -> Result<bool, MapTileCacheError> {
1073 for zoom_level in (1..=8).rev() {
1074 tracing::debug!(
1075 "Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
1076 );
1077 let map_tile_descriptor = MapTileDescriptor::new(
1078 ZoomLevel::try_new(zoom_level)?,
1079 grid_coordinates.to_owned(),
1080 );
1081 if !self.does_map_tile_exist(&map_tile_descriptor).await? {
1082 tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
1083 return Ok(false);
1084 }
1085 let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
1086 if cache_entry_status == MapTileCacheEntryStatus::Valid {}
1087 }
1088 tracing::debug!(
1089 "Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
1090 );
1091 Ok(true)
1092 }
1093}
1094
1095#[derive(Debug, Clone)]
1097pub struct Map {
1098 zoom_level: ZoomLevel,
1100 grid_rectangle: GridRectangle,
1102 image: image::DynamicImage,
1104}
1105
1106#[derive(Debug, thiserror::Error)]
1108pub enum MapError {
1109 #[error("error in map tile cache while assembling map: {0}")]
1111 MapTileCacheError(#[from] MapTileCacheError),
1112 #[error(
1115 "error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
1116 )]
1117 ZoomFitError(#[from] ZoomFitError),
1118 #[error("error when cropping a map tile to the required size")]
1120 MapTileCropError,
1121 #[error("error when calculating pixel coordinates where we want to place a map tile crop")]
1123 MapCoordinateError,
1124 #[error("no overlap between map tile we fetched and output map (should not happen)")]
1126 NoOverlapError,
1127 #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
1130 NoGridCoordinatesForRegion(RegionName),
1131 #[error("error in region name to grid coordinate cache: {0}")]
1133 RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
1134 #[error("error calculating spline: {0}")]
1136 SplineError(
1137 #[source]
1138 #[from]
1139 uniform_cubic_splines::SplineError,
1140 ),
1141}
1142
1143impl Map {
1144 pub async fn new(
1164 map_tile_cache: &mut MapTileCache,
1165 x: u32,
1166 y: u32,
1167 grid_rectangle: GridRectangle,
1168 fill_missing_map_tiles: Option<image::Rgba<u8>>,
1169 fill_missing_regions: Option<image::Rgba<u8>>,
1170 ) -> Result<Self, MapError> {
1171 Self::new_with_progress(
1172 map_tile_cache,
1173 x,
1174 y,
1175 grid_rectangle,
1176 fill_missing_map_tiles,
1177 fill_missing_regions,
1178 None,
1179 )
1180 .await
1181 }
1182
1183 pub async fn new_with_progress(
1194 map_tile_cache: &mut MapTileCache,
1195 x: u32,
1196 y: u32,
1197 grid_rectangle: GridRectangle,
1198 fill_missing_map_tiles: Option<image::Rgba<u8>>,
1199 fill_missing_regions: Option<image::Rgba<u8>>,
1200 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1201 ) -> Result<Self, MapError> {
1202 let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1203 grid_rectangle.size_x(),
1204 grid_rectangle.size_y(),
1205 x,
1206 y,
1207 )?;
1208 let actual_x = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1209 * <u16 as Into<u32>>::into(grid_rectangle.size_x());
1210 let actual_y = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1211 * <u16 as Into<u32>>::into(grid_rectangle.size_y());
1212 tracing::debug!(
1213 "Determined max zoom level for map of size ({x}, {y}) for {grid_rectangle:?} to be {zoom_level:?}, actual map size will be ({actual_x}, {actual_y})"
1214 );
1215 let x = actual_x;
1216 let y = actual_y;
1217 let image = image::DynamicImage::new_rgb8(x, y);
1218 let mut result = Self {
1219 zoom_level,
1220 grid_rectangle,
1221 image,
1222 };
1223 let mut total_tiles: u32 = 0;
1226 for region_x in result.x_range() {
1227 for region_y in result.y_range() {
1228 let grid_coordinates = GridCoordinates::new(region_x, region_y);
1229 let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1230 let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1231 return Err(MapError::NoOverlapError);
1232 };
1233 if overlap.lower_left_corner().x() == region_x
1234 && overlap.lower_left_corner().y() == region_y
1235 {
1236 total_tiles = total_tiles.saturating_add(1);
1237 }
1238 }
1239 }
1240 emit_progress(
1241 progress,
1242 MapProgressEvent::PlanComputed {
1243 zoom_level,
1244 total_tiles,
1245 },
1246 );
1247 if fill_missing_regions.is_some() {
1248 let total_regions: u32 =
1253 u32::from(result.size_x()).saturating_mul(u32::from(result.size_y()));
1254 emit_progress(
1255 progress,
1256 MapProgressEvent::RegionCheckPlanned { total_regions },
1257 );
1258 }
1259 for region_x in result.x_range() {
1260 for region_y in result.y_range() {
1261 let grid_coordinates = GridCoordinates::new(region_x, region_y);
1262 let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1263 let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1264 return Err(MapError::NoOverlapError);
1265 };
1266 if overlap.lower_left_corner().x() != region_x
1267 || overlap.lower_left_corner().y() != region_y
1268 {
1269 continue;
1272 }
1273 tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1274 emit_progress(
1275 progress,
1276 MapProgressEvent::TileStarted {
1277 descriptor: map_tile_descriptor.to_owned(),
1278 },
1279 );
1280 let (fetched_tile, outcome) = map_tile_cache
1281 .get_map_tile_with_outcome(&map_tile_descriptor)
1282 .await?;
1283 emit_progress(
1284 progress,
1285 MapProgressEvent::TileFinished {
1286 descriptor: map_tile_descriptor.to_owned(),
1287 outcome,
1288 },
1289 );
1290 if let Some(map_tile) = fetched_tile {
1291 let crop = map_tile
1292 .crop_imm_grid_rectangle(&overlap)
1293 .ok_or(MapError::MapTileCropError)?;
1294 tracing::debug!(
1295 "Cropped map tile to ({}, {})+{}x{}",
1296 crop.offsets().0,
1297 crop.offsets().1,
1298 (*crop).dimensions().0,
1299 (*crop).dimensions().1
1300 );
1301 let (replace_x, replace_y) = result
1304 .pixel_coordinates_for_coordinates(
1305 &overlap.upper_left_corner(),
1306 &RegionCoordinates::new(0f32, 256f32, 0f32),
1307 )
1308 .ok_or(MapError::MapCoordinateError)?;
1309 tracing::debug!(
1310 "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1311 );
1312 image::imageops::replace(
1313 &mut result,
1314 &*crop,
1315 replace_x.into(),
1316 replace_y.into(),
1317 );
1318 if let Some(fill_color) = fill_missing_regions {
1319 for overlap_region_x in overlap.x_range() {
1320 for overlap_region_y in overlap.y_range() {
1321 let grid_coordinates =
1322 GridCoordinates::new(overlap_region_x, overlap_region_y);
1323 let exists =
1324 map_tile_cache.does_region_exist(&grid_coordinates).await?;
1325 emit_progress(
1326 progress,
1327 MapProgressEvent::RegionChecked {
1328 x: overlap_region_x,
1329 y: overlap_region_y,
1330 exists,
1331 },
1332 );
1333 if !exists {
1334 let pixel_min = result.pixel_coordinates_for_coordinates(
1335 &grid_coordinates,
1336 &RegionCoordinates::new(0f32, 256f32, 0f32),
1337 );
1338 let pixel_max = result.pixel_coordinates_for_coordinates(
1339 &grid_coordinates,
1340 &RegionCoordinates::new(256f32, 0f32, 0f32),
1341 );
1342 if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1343 (pixel_min, pixel_max)
1344 {
1345 for x in min_x..max_x {
1346 for y in min_y..max_y {
1347 <Self as image::GenericImage>::put_pixel(
1348 &mut result,
1349 x,
1350 y,
1351 fill_color,
1352 );
1353 }
1354 }
1355 }
1356 }
1357 }
1358 }
1359 }
1360 } else if let Some(fill_color) = fill_missing_map_tiles {
1361 let (replace_x, replace_y) = result
1362 .pixel_coordinates_for_coordinates(
1363 &overlap.upper_left_corner(),
1364 &RegionCoordinates::new(0f32, 256f32, 0f32),
1365 )
1366 .ok_or(MapError::MapCoordinateError)?;
1367 let pixel_size_x =
1368 u32::from(overlap.size_x()) * u32::from(zoom_level.pixels_per_region());
1369 let pixel_size_y =
1370 u32::from(overlap.size_y()) * u32::from(zoom_level.pixels_per_region());
1371 for x in replace_x..replace_x + pixel_size_x {
1372 for y in replace_y..replace_y + pixel_size_y {
1373 <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1374 }
1375 }
1376 }
1377 }
1378 }
1379 Ok(result)
1380 }
1381
1382 pub async fn draw_route(
1389 &mut self,
1390 region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1391 usb_notecard: &USBNotecard,
1392 color: image::Rgba<u8>,
1393 ) -> Result<(), MapError> {
1394 self.draw_route_with_progress(
1395 region_name_to_grid_coordinates_cache,
1396 usb_notecard,
1397 color,
1398 None,
1399 )
1400 .await
1401 }
1402
1403 pub async fn draw_route_with_progress(
1413 &mut self,
1414 region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1415 usb_notecard: &USBNotecard,
1416 color: image::Rgba<u8>,
1417 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1418 ) -> Result<(), MapError> {
1419 tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1420 let waypoints = usb_notecard.waypoints();
1421 let total_waypoints = waypoints.len();
1422 emit_progress(progress, MapProgressEvent::RoutePlanned { total_waypoints });
1423 let mut pixel_waypoints = Vec::new();
1424 for (index, waypoint) in waypoints.iter().enumerate() {
1425 let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1426 .get_grid_coordinates(waypoint.location().region_name())
1427 .await?
1428 else {
1429 return Err(MapError::NoGridCoordinatesForRegion(
1430 waypoint.location().region_name().to_owned(),
1431 ));
1432 };
1433 emit_progress(
1434 progress,
1435 MapProgressEvent::RouteWaypointResolved {
1436 index,
1437 total: total_waypoints,
1438 region: waypoint.location().region_name().to_owned(),
1439 },
1440 );
1441 let (x, y) = self
1442 .pixel_coordinates_for_coordinates(
1443 &grid_coordinates,
1444 &waypoint.region_coordinates(),
1445 )
1446 .ok_or(MapError::MapCoordinateError)?;
1447 tracing::debug!(
1448 "Drawing waypoint at ({x}, {y}) for location {:?}",
1449 waypoint.location()
1450 );
1451 #[expect(
1453 clippy::cast_precision_loss,
1454 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1455 )]
1456 pixel_waypoints.push((x as f32, y as f32));
1457 }
1458 self.draw_pixel_waypoint_route(&pixel_waypoints, color)?;
1459 Ok(())
1460 }
1461
1462 pub fn draw_pixel_waypoint_route(
1479 &mut self,
1480 pixel_waypoints: &[(f32, f32)],
1481 color: image::Rgba<u8>,
1482 ) -> Result<(), uniform_cubic_splines::SplineError> {
1483 let waypoint_count = pixel_waypoints.len();
1484 let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1485 return Ok(());
1487 };
1488 let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1489 else {
1490 return Ok(());
1492 };
1493 let extra_before_start = (
1494 first.0 - (second.0 - first.0),
1495 first.1 - (second.1 - first.1),
1496 );
1497 let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1498 return Ok(());
1500 };
1501 let Some((second_to_last, _pixel_waypoints_rest)) =
1502 pixel_waypoints_all_but_last.split_last()
1503 else {
1504 return Ok(());
1506 };
1507 let extra_after_end = (
1508 last.0 + (last.0 - second_to_last.0),
1509 last.1 + (last.1 - second_to_last.1),
1510 );
1511 let mut knots = vec![extra_before_start];
1512 knots.extend(pixel_waypoints.to_owned());
1513 knots.push(extra_after_end);
1514 let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1515 let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1516 let point_x =
1517 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1518 v, &points_x,
1519 )?;
1520 let point_y =
1521 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1522 v, &points_y,
1523 )?;
1524 Ok((point_x, point_y))
1525 };
1526 #[expect(
1533 clippy::cast_precision_loss,
1534 reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1535 )]
1536 let waypoint_parameter_denominator = if waypoint_count <= 2 {
1537 (waypoint_count as f32 - 1f32).max(1f32)
1538 } else {
1539 waypoint_count as f32 - 2f32
1540 };
1541 let spline_value_for_waypoint = |i: usize| -> f32 {
1542 #[expect(
1543 clippy::cast_precision_loss,
1544 reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1545 )]
1546 let i = i as f32;
1547 i / waypoint_parameter_denominator
1548 };
1549 let spline_value_between_waypoints = spline_value_for_waypoint(1);
1550 let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1551 ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1552 };
1553 let mut last_point: Option<(f32, f32)> = None;
1554 let outer_loop_count = if waypoint_count <= 2 {
1559 waypoint_count
1560 } else {
1561 waypoint_count - 1
1562 };
1563 for (i, waypoint) in pixel_waypoints.iter().enumerate().take(outer_loop_count) {
1564 const SPLINE_RECT_SIZE: u8 = 3;
1567 tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1568 let v = spline_value_for_waypoint(i);
1569 let point = sample(v)?;
1570 tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1571 if let Some(last_point) = last_point {
1572 let distance_from_last_point = distance_between_points(point, last_point);
1573 tracing::debug!(
1574 "Waypoint {i} is {:?} from last waypoint",
1575 distance_from_last_point
1576 );
1577 #[expect(
1578 clippy::cast_possible_truncation,
1579 reason = "we want an integer count for the number of samples"
1580 )]
1581 #[expect(
1582 clippy::cast_sign_loss,
1583 reason = "we want a positive count for the number of samples"
1584 )]
1585 let samples_between_last_waypoint_and_this_one =
1586 (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1587 #[expect(
1595 clippy::cast_precision_loss,
1596 reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1597 )]
1598 let sample_step_denominator =
1599 (samples_between_last_waypoint_and_this_one as f32 - 2f32).max(1f32);
1600 for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1601 #[expect(
1602 clippy::cast_precision_loss,
1603 reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1604 )]
1605 let v =
1606 v - spline_value_between_waypoints * (j as f32 / sample_step_denominator);
1607 let sample_point = sample(v)?;
1608 #[expect(
1609 clippy::cast_possible_truncation,
1610 reason = "we want integer pixel coordinates for use in the image library"
1611 )]
1612 imageproc::drawing::draw_filled_rect_mut(
1613 self.image_mut(),
1614 imageproc::rect::Rect::at(
1615 sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1616 sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1617 )
1618 .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1619 color,
1620 );
1621 }
1622 self.draw_arrow(
1623 sample(v - (0.1f32 * spline_value_between_waypoints))?,
1624 point,
1625 color,
1626 );
1627 }
1628 last_point = Some(point);
1629 }
1630 Ok(())
1631 }
1632
1633 #[must_use]
1645 pub fn blank(grid_rectangle: GridRectangle, zoom_level: ZoomLevel) -> Self {
1646 let width = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1647 * <u16 as Into<u32>>::into(grid_rectangle.size_x());
1648 let height = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
1649 * <u16 as Into<u32>>::into(grid_rectangle.size_y());
1650 Self {
1651 zoom_level,
1652 grid_rectangle,
1653 image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1654 }
1655 }
1656
1657 #[expect(
1670 clippy::result_large_err,
1671 reason = "returns the same large MapError as the other Map constructors for a consistent error type at call sites; the only failure here is the zoom-fit calculation"
1672 )]
1673 pub fn blank_fit(
1674 grid_rectangle: GridRectangle,
1675 max_width: u32,
1676 max_height: u32,
1677 ) -> Result<Self, MapError> {
1678 let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1679 grid_rectangle.size_x(),
1680 grid_rectangle.size_y(),
1681 max_width,
1682 max_height,
1683 )?;
1684 Ok(Self::blank(grid_rectangle, zoom_level))
1685 }
1686
1687 #[cfg(test)]
1691 fn new_blank_for_test(width: u32, height: u32) -> Self {
1692 #[expect(
1693 clippy::expect_used,
1694 reason = "zoom level 1 is a compile-time constant within the valid range"
1695 )]
1696 let zoom_level = ZoomLevel::try_new(1).expect("zoom level 1 is within the valid range");
1697 Self {
1698 zoom_level,
1699 grid_rectangle: GridRectangle::new(
1700 GridCoordinates::new(0, 0),
1701 GridCoordinates::new(0, 0),
1702 ),
1703 image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1704 }
1705 }
1706
1707 pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1714 self.image.save(path)
1715 }
1716}
1717
1718impl GridRectangleLike for Map {
1719 fn grid_rectangle(&self) -> GridRectangle {
1720 self.grid_rectangle.to_owned()
1721 }
1722}
1723
1724impl image::GenericImageView for Map {
1725 type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1726
1727 fn dimensions(&self) -> (u32, u32) {
1728 self.image.dimensions()
1729 }
1730
1731 fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1732 self.image.get_pixel(x, y)
1733 }
1734}
1735
1736impl image::GenericImage for Map {
1737 fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1738 #[expect(
1739 deprecated,
1740 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1741 )]
1742 self.image.get_pixel_mut(x, y)
1743 }
1744
1745 fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1746 self.image.put_pixel(x, y, pixel);
1747 }
1748
1749 fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1750 #[expect(
1751 deprecated,
1752 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1753 )]
1754 self.image.blend_pixel(x, y, pixel);
1755 }
1756}
1757
1758impl MapLike for Map {
1759 fn zoom_level(&self) -> ZoomLevel {
1760 self.zoom_level
1761 }
1762
1763 fn image(&self) -> &image::DynamicImage {
1764 &self.image
1765 }
1766
1767 fn image_mut(&mut self) -> &mut image::DynamicImage {
1768 &mut self.image
1769 }
1770}
1771
1772#[cfg(test)]
1773mod test {
1774 use image::GenericImageView as _;
1775 use tracing_test::traced_test;
1776
1777 use super::*;
1778
1779 #[tokio::test]
1780 async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1781 let temp_dir = tempfile::tempdir()?;
1782 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1783 map_tile_cache
1784 .get_map_tile(&MapTileDescriptor::new(
1785 ZoomLevel::try_new(1)?,
1786 GridCoordinates::new(1136, 1075),
1787 ))
1788 .await?;
1789 Ok(())
1790 }
1791
1792 #[tokio::test]
1793 async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1794 let temp_dir = tempfile::tempdir()?;
1795 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1796 map_tile_cache
1797 .get_map_tile(&MapTileDescriptor::new(
1798 ZoomLevel::try_new(1)?,
1799 GridCoordinates::new(1136, 1075),
1800 ))
1801 .await?;
1802 map_tile_cache
1803 .get_map_tile(&MapTileDescriptor::new(
1804 ZoomLevel::try_new(1)?,
1805 GridCoordinates::new(1136, 1075),
1806 ))
1807 .await?;
1808 Ok(())
1809 }
1810
1811 #[tokio::test]
1812 async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1813 let temp_dir = tempfile::tempdir()?;
1814 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1815 map_tile_cache
1816 .get_map_tile(&MapTileDescriptor::new(
1817 ZoomLevel::try_new(8)?,
1818 GridCoordinates::new(1136, 1075),
1819 ))
1820 .await?;
1821 Ok(())
1822 }
1823
1824 #[traced_test]
1825 #[tokio::test]
1826 async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1827 let temp_dir = tempfile::tempdir()?;
1828 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1829 let mut map_tile_cache =
1830 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1831 let map = Map::new(
1832 &mut map_tile_cache,
1833 512,
1834 512,
1835 GridRectangle::new(
1836 GridCoordinates::new(1135, 1070),
1837 GridCoordinates::new(1136, 1071),
1838 ),
1839 None,
1840 None,
1841 )
1842 .await?;
1843 map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1844 Ok(())
1845 }
1846
1847 #[traced_test]
1848 #[tokio::test]
1849 async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1850 let temp_dir = tempfile::tempdir()?;
1851 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1852 let mut map_tile_cache =
1853 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1854 let map = Map::new(
1855 &mut map_tile_cache,
1856 256,
1857 256,
1858 GridRectangle::new(
1859 GridCoordinates::new(1136, 1074),
1860 GridCoordinates::new(1137, 1075),
1861 ),
1862 None,
1863 None,
1864 )
1865 .await?;
1866 map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1867 Ok(())
1868 }
1869
1870 #[traced_test]
1871 #[tokio::test]
1872 async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1873 let temp_dir = tempfile::tempdir()?;
1874 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1875 let mut map_tile_cache =
1876 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1877 let map = Map::new(
1878 &mut map_tile_cache,
1879 128,
1880 128,
1881 GridRectangle::new(
1882 GridCoordinates::new(1136, 1074),
1883 GridCoordinates::new(1137, 1075),
1884 ),
1885 None,
1886 None,
1887 )
1888 .await?;
1889 map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1890 Ok(())
1891 }
1892
1893 #[traced_test]
1894 #[tokio::test]
1895 async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1896 let temp_dir = tempfile::tempdir()?;
1897 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1898 let mut map_tile_cache =
1899 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1900 let map = Map::new(
1901 &mut map_tile_cache,
1902 2048,
1903 2048,
1904 GridRectangle::new(
1905 GridCoordinates::new(1131, 1068),
1906 GridCoordinates::new(1139, 1075),
1907 ),
1908 None,
1909 None,
1910 )
1911 .await?;
1912 map.save(std::path::Path::new(
1913 "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1914 ))?;
1915 Ok(())
1916 }
1917
1918 #[traced_test]
1919 #[tokio::test]
1920 #[expect(clippy::panic, reason = "panic in test is intentional")]
1921 async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1922 -> Result<(), Box<dyn std::error::Error>> {
1923 let temp_dir = tempfile::tempdir()?;
1924 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1925 let Some(map_tile) = map_tile_cache
1926 .get_map_tile(&MapTileDescriptor::new(
1927 ZoomLevel::try_new(1)?,
1928 GridCoordinates::new(1136, 1075),
1929 ))
1930 .await?
1931 else {
1932 panic!("Expected there to be a region at this location");
1933 };
1934 for in_region_x in 0..=256 {
1935 for in_region_y in 0..=256 {
1936 let grid_coordinates = GridCoordinates::new(1136, 1075);
1937 #[expect(
1938 clippy::cast_precision_loss,
1939 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
1940 )]
1941 let region_coordinates =
1942 RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
1943 tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
1944 assert_eq!(
1945 map_tile
1946 .pixel_coordinates_for_coordinates(&grid_coordinates, ®ion_coordinates,),
1947 Some((in_region_x, 256 - in_region_y)),
1948 );
1949 }
1950 }
1951 Ok(())
1952 }
1953
1954 #[traced_test]
1955 #[tokio::test]
1956 async fn test_map_pixel_coordinates_for_coordinates_four_regions()
1957 -> Result<(), Box<dyn std::error::Error>> {
1958 let temp_dir = tempfile::tempdir()?;
1959 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1960 let mut map_tile_cache =
1961 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1962 let map = Map::new(
1963 &mut map_tile_cache,
1964 512,
1965 512,
1966 GridRectangle::new(
1967 GridCoordinates::new(1136, 1074),
1968 GridCoordinates::new(1137, 1075),
1969 ),
1970 None,
1971 None,
1972 )
1973 .await?;
1974 for region_offset_x in 0..=1 {
1975 for region_offset_y in 0..=1 {
1976 for in_region_x in 0..=256 {
1977 for in_region_y in 0..=256 {
1978 let grid_coordinates =
1979 GridCoordinates::new(1136 + region_offset_x, 1074 + region_offset_y);
1980 let region_coordinates = RegionCoordinates::new(
1981 f32::from(in_region_x),
1982 f32::from(in_region_y),
1983 0f32,
1984 );
1985 tracing::debug!(
1986 "Now checking {grid_coordinates:?}, {region_coordinates:?}"
1987 );
1988 assert_eq!(
1989 map.pixel_coordinates_for_coordinates(
1990 &grid_coordinates,
1991 ®ion_coordinates,
1992 ),
1993 Some((
1994 u32::from(region_offset_x * 256 + in_region_x),
1995 u32::from(512 - (region_offset_y * 256 + in_region_y))
1996 )),
1997 );
1998 }
1999 }
2000 }
2001 }
2002 Ok(())
2003 }
2004
2005 #[traced_test]
2006 #[tokio::test]
2007 #[expect(clippy::panic, reason = "panic in test is intentional")]
2008 async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
2009 -> Result<(), Box<dyn std::error::Error>> {
2010 let temp_dir = tempfile::tempdir()?;
2011 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2012 let Some(map_tile) = map_tile_cache
2013 .get_map_tile(&MapTileDescriptor::new(
2014 ZoomLevel::try_new(1)?,
2015 GridCoordinates::new(1136, 1075),
2016 ))
2017 .await?
2018 else {
2019 panic!("Expected there to be a region at this location");
2020 };
2021 tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
2022 #[expect(
2023 clippy::cast_precision_loss,
2024 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
2025 )]
2026 for in_region_x in 0..=256 {
2027 for in_region_y in 0..=256 {
2028 let pixel_x = in_region_x;
2029 let pixel_y = 256 - in_region_y;
2030 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2031 assert_eq!(
2032 map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2033 Some((
2034 GridCoordinates::new(
2035 1136 + if in_region_x == 256 { 1 } else { 0 },
2036 1075 + if in_region_y == 256 { 1 } else { 0 }
2037 ),
2038 RegionCoordinates::new(
2039 (in_region_x % 256) as f32,
2040 (in_region_y % 256) as f32,
2041 0f32
2042 ),
2043 ))
2044 );
2045 }
2046 }
2047 Ok(())
2048 }
2049
2050 #[traced_test]
2051 #[tokio::test]
2052 async fn test_map_coordinates_for_pixel_coordinates_four_regions()
2053 -> Result<(), Box<dyn std::error::Error>> {
2054 let temp_dir = tempfile::tempdir()?;
2055 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
2056 let mut map_tile_cache =
2057 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
2058 let map = Map::new(
2059 &mut map_tile_cache,
2060 512,
2061 512,
2062 GridRectangle::new(
2063 GridCoordinates::new(1136, 1074),
2064 GridCoordinates::new(1137, 1075),
2065 ),
2066 None,
2067 None,
2068 )
2069 .await?;
2070 tracing::debug!("Dimensions of map are {:?}", map.dimensions());
2071 for region_offset_x in 0..=1 {
2072 for region_offset_y in 0..=1 {
2073 for in_region_x in 0..=256 {
2074 for in_region_y in 0..=256 {
2075 let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
2076 let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
2077 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2078 assert_eq!(
2079 map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2080 Some((
2081 GridCoordinates::new(
2082 1136 + region_offset_x + if in_region_x == 256 { 1 } else { 0 },
2083 1074 + region_offset_y + if in_region_y == 256 { 1 } else { 0 }
2084 ),
2085 RegionCoordinates::new(
2086 f32::from(in_region_x % 256),
2087 f32::from(in_region_y % 256),
2088 0f32
2089 ),
2090 )),
2091 );
2092 }
2093 }
2094 }
2095 }
2096 Ok(())
2097 }
2098
2099 #[cfg(test)]
2101 const BLANK_PIXEL: [u8; 4] = [0, 0, 0, 0];
2102
2103 #[cfg(test)]
2105 fn drawn_pixel_count(map: &Map) -> usize {
2106 map.image()
2107 .pixels()
2108 .filter(|(_, _, pixel)| pixel.0 != BLANK_PIXEL)
2109 .count()
2110 }
2111
2112 #[test]
2117 fn test_draw_route_identical_consecutive_waypoints_is_safe()
2118 -> Result<(), Box<dyn std::error::Error>> {
2119 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2120 let mut map = Map::new_blank_for_test(256, 256);
2121 let pixel_waypoints = vec![
2123 (100f32, 100f32),
2124 (120f32, 120f32),
2125 (120f32, 120f32),
2126 (140f32, 100f32),
2127 ];
2128 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2129 assert!(
2130 drawn_pixel_count(&map) > 0,
2131 "the route should still draw at least one pixel"
2132 );
2133 assert_eq!(
2134 map.image().get_pixel(0, 0).0,
2135 BLANK_PIXEL,
2136 "the (0,0) corner must stay background (no NaN-derived rectangle)"
2137 );
2138 Ok(())
2139 }
2140
2141 #[test]
2145 fn test_draw_route_two_waypoints_renders() -> Result<(), Box<dyn std::error::Error>> {
2146 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2147 let mut map = Map::new_blank_for_test(256, 256);
2148 let pixel_waypoints = vec![(60f32, 60f32), (180f32, 180f32)];
2149 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2150 assert!(
2151 drawn_pixel_count(&map) > 0,
2152 "a two-waypoint route must draw a visible curve (regression for the waypoint_count - 2 == 0 bug)"
2153 );
2154 assert_eq!(
2155 map.image().get_pixel(0, 0).0,
2156 BLANK_PIXEL,
2157 "the (0,0) corner must stay background"
2158 );
2159 Ok(())
2160 }
2161
2162 #[test]
2167 fn test_draw_route_two_sample_segment_has_no_corner_glitch()
2168 -> Result<(), Box<dyn std::error::Error>> {
2169 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2170 let mut map = Map::new_blank_for_test(256, 256);
2171 let pixel_waypoints = vec![(100f32, 100f32), (110f32, 110f32)];
2172 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2173 assert_eq!(
2174 map.image().get_pixel(0, 0).0,
2175 BLANK_PIXEL,
2176 "samples == 2 must not draw a NaN-derived rectangle at the (0,0) corner"
2177 );
2178 Ok(())
2179 }
2180
2181 #[test]
2185 fn test_real_notecard_with_duplicate_consecutive_lines_parses()
2186 -> Result<(), Box<dyn std::error::Error>> {
2187 let notecard: USBNotecard =
2188 include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2189 let has_identical_consecutive = notecard
2190 .waypoints()
2191 .windows(2)
2192 .any(|pair| matches!(pair, [first, second] if first.location() == second.location()));
2193 assert!(
2194 has_identical_consecutive,
2195 "tscc-2026-03-30.txt is expected to contain identical consecutive waypoints"
2196 );
2197 Ok(())
2198 }
2199
2200 #[tokio::test]
2203 async fn test_draw_real_route_with_duplicate_line() -> Result<(), Box<dyn std::error::Error>> {
2204 let notecard: USBNotecard =
2205 include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2206 let temp_dir = tempfile::tempdir()?;
2207 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2208 let mut region_cache =
2209 RegionNameToGridCoordinatesCache::new(temp_dir.path().to_path_buf())?;
2210 let grid_rectangle =
2211 crate::region::usb_notecard_to_grid_rectangle(&mut region_cache, ¬ecard).await?;
2212 let mut map = Map::new(&mut map_tile_cache, 512, 512, grid_rectangle, None, None).await?;
2213 map.draw_route_with_progress(
2214 &mut region_cache,
2215 ¬ecard,
2216 image::Rgba([255u8, 0u8, 0u8, 255u8]),
2217 None,
2218 )
2219 .await?;
2220 Ok(())
2221 }
2222}