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: u32,
427 y: u32,
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
590pub const DEFAULT_MAP_TILE_BASE_URL: &str = "https://secondlife-maps-cdn.akamaized.net/";
593
594#[derive(derive_more::Debug)]
596pub struct MapTileCache {
597 client: reqwest::Client,
599 base_url: String,
605 #[debug(skip)]
607 ratelimiter: Option<ratelimit::Ratelimiter>,
608 cache_directory: PathBuf,
610 #[debug(skip)]
612 cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617pub enum MapTileCacheEntryStatus {
618 Missing,
620 Invalid,
622 Valid,
624}
625
626#[derive(Debug)]
629pub struct MapTileNegativeResponse(reqwest::Response);
630
631impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
632 fn status(&self) -> http::status::StatusCode {
633 match self.0.status() {
634 http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
635 status => status,
636 }
637 }
638
639 fn headers(&self) -> &http::header::HeaderMap {
640 self.0.headers()
641 }
642}
643
644impl MapTileCache {
645 #[must_use]
648 pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
649 Self::new_with_base_url(
650 cache_directory,
651 ratelimiter,
652 DEFAULT_MAP_TILE_BASE_URL.to_owned(),
653 )
654 }
655
656 #[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
660 #[must_use]
661 pub fn new_with_base_url(
662 cache_directory: PathBuf,
663 ratelimiter: Option<ratelimit::Ratelimiter>,
664 base_url: String,
665 ) -> Self {
666 #[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
667 let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
668 let base_url = if base_url.ends_with('/') {
669 base_url
670 } else {
671 format!("{base_url}/")
672 };
673 Self {
674 client: reqwest::Client::new(),
675 base_url,
676 ratelimiter,
677 cache_directory,
678 cache,
679 }
680 }
681
682 #[must_use]
684 fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
685 format!(
686 "map-{}-{}-{}-objects.jpg",
687 map_tile_descriptor.zoom_level(),
688 map_tile_descriptor.lower_left_corner().x(),
689 map_tile_descriptor.lower_left_corner().y(),
690 )
691 }
692
693 #[must_use]
695 fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
696 self.cache_directory
697 .join(Self::map_tile_file_name(map_tile_descriptor))
698 }
699
700 #[must_use]
702 fn map_tile_cache_negative_response_file_name(
703 &self,
704 map_tile_descriptor: &MapTileDescriptor,
705 ) -> PathBuf {
706 self.cache_directory.join(format!(
707 "{}.does-not-exist",
708 Self::map_tile_file_name(map_tile_descriptor)
709 ))
710 }
711
712 #[must_use]
714 fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
715 self.cache_directory.join(format!(
716 "{}.cache-policy.json",
717 Self::map_tile_file_name(map_tile_descriptor)
718 ))
719 }
720
721 #[must_use]
723 fn map_tile_url(&self, map_tile_descriptor: &MapTileDescriptor) -> String {
724 format!(
725 "{}{}",
726 self.base_url,
727 Self::map_tile_file_name(map_tile_descriptor),
728 )
729 }
730
731 async fn cache_entry_status(
733 &self,
734 map_tile_descriptor: &MapTileDescriptor,
735 ) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
736 match (
737 self.cache_policy_file_name(map_tile_descriptor).exists(),
738 self.map_tile_cache_file_name(map_tile_descriptor).exists(),
739 self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
740 .exists(),
741 ) {
742 (false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
743 (true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
744 (cp, tile, neg) => {
745 tracing::warn!(
746 "cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
747 cp,
748 tile,
749 neg
750 );
751 Ok(MapTileCacheEntryStatus::Invalid)
752 }
753 }
754 }
755
756 async fn fetch_cached_map_tile(
763 &mut self,
764 map_tile_descriptor: &MapTileDescriptor,
765 ) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
766 {
767 if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
768 return Ok(Some(cache_entry.to_owned()));
769 }
770 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
771 let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
772 if cache_entry_status == MapTileCacheEntryStatus::Invalid {
773 self.remove_cached_tile(map_tile_descriptor).await?;
774 return Ok(None);
775 }
776 if cache_entry_status == MapTileCacheEntryStatus::Missing {
777 return Ok(None);
778 }
779 let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
780 return Err(MapTileCacheError::CachePolicyError);
781 };
782 if cache_file.exists() {
783 let cached_map_tile = image::ImageReader::open(cache_file)
784 .map_err(MapTileCacheError::CacheDirectoryFileError)?
785 .decode()?;
786 Ok(Some((
787 Some(MapTile {
788 descriptor: map_tile_descriptor.to_owned(),
789 image: cached_map_tile,
790 }),
791 cache_policy,
792 )))
793 } else {
794 Ok(Some((None, cache_policy)))
796 }
797 }
798
799 async fn remove_cached_tile(
801 &mut self,
802 map_tile_descriptor: &MapTileDescriptor,
803 ) -> Result<(), MapTileCacheError> {
804 tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
805 self.cache.pop(map_tile_descriptor);
806 let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
807 let cache_file_negative_response =
808 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
809 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
810 if cache_file.exists() {
811 std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
812 }
813 if cache_file_negative_response.exists() {
814 std::fs::remove_file(cache_file_negative_response)
815 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
816 }
817 if cache_policy_file.exists() {
818 std::fs::remove_file(cache_policy_file)
819 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
820 }
821 Ok(())
822 }
823
824 async fn load_cache_policy(
831 &self,
832 map_tile_descriptor: &MapTileDescriptor,
833 ) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
834 let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
835 if !cache_policy_file.exists() {
836 return Ok(None);
837 }
838 let cache_policy = std::fs::read_to_string(cache_policy_file)
839 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
840 Ok(serde_json::from_str(&cache_policy)?)
841 }
842
843 async fn store_cache_policy(
850 &self,
851 map_tile_descriptor: &MapTileDescriptor,
852 cache_policy: http_cache_semantics::CachePolicy,
853 ) -> Result<(), MapTileCacheError> {
854 if !self.cache_directory.exists() {
855 std::fs::create_dir_all(&self.cache_directory)
856 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
857 }
858 let cache_policy = serde_json::to_string(&cache_policy)?;
859 std::fs::write(
860 self.cache_policy_file_name(map_tile_descriptor),
861 cache_policy,
862 )
863 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
864 Ok(())
865 }
866
867 async fn cache_missing_tile(
875 &mut self,
876 map_tile_descriptor: &MapTileDescriptor,
877 cache_policy: http_cache_semantics::CachePolicy,
878 ) -> Result<(), MapTileCacheError> {
879 if cache_policy.is_storable() {
880 tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
881 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
882 .await?;
883 let cache_file_negative_response =
884 self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
885 std::fs::File::create(cache_file_negative_response)
886 .map_err(MapTileCacheError::CacheDirectoryFileError)?;
887 self.cache
888 .put(map_tile_descriptor.clone(), (None, cache_policy));
889 } else {
890 tracing::warn!(
891 "Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
892 );
893 }
894 Ok(())
895 }
896
897 async fn cache_tile(
905 &mut self,
906 map_tile_descriptor: &MapTileDescriptor,
907 map_tile: &MapTile,
908 cache_policy: http_cache_semantics::CachePolicy,
909 ) -> Result<(), MapTileCacheError> {
910 if cache_policy.is_storable() {
911 tracing::debug!("Caching map tile {map_tile_descriptor:?}");
912 self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
913 .await?;
914 map_tile
915 .image
916 .save(self.map_tile_cache_file_name(map_tile_descriptor))?;
917 self.cache.put(
918 map_tile_descriptor.clone(),
919 (Some(map_tile.to_owned()), cache_policy),
920 );
921 } else {
922 tracing::warn!(
923 "Map tile {map_tile_descriptor:?} not storable according to cache policy"
924 );
925 }
926 Ok(())
927 }
928
929 pub async fn get_map_tile(
937 &mut self,
938 map_tile_descriptor: &MapTileDescriptor,
939 ) -> Result<Option<MapTile>, MapTileCacheError> {
940 Ok(self.get_map_tile_with_outcome(map_tile_descriptor).await?.0)
941 }
942
943 pub async fn get_map_tile_with_outcome(
953 &mut self,
954 map_tile_descriptor: &MapTileDescriptor,
955 ) -> Result<(Option<MapTile>, TileOutcome), MapTileCacheError> {
956 tracing::debug!("Map tile {map_tile_descriptor:?} requested");
957 let url = self.map_tile_url(map_tile_descriptor);
958 let request = self.client.get(&url).build()?;
959 let now = std::time::SystemTime::now();
960 let memory_cache_hit = self.cache.peek(map_tile_descriptor).is_some();
963 if let Some((cached_map_tile, cache_policy)) =
964 self.fetch_cached_map_tile(map_tile_descriptor).await?
965 {
966 if cached_map_tile.is_some() {
967 tracing::debug!("Found matching map tile in cache, checking freshness");
968 } else {
969 tracing::debug!("Found matching map tile absence in cache, checking freshness");
970 }
971 if let http_cache_semantics::BeforeRequest::Fresh(_) =
972 cache_policy.before_request(&request, now)
973 {
974 if cached_map_tile.is_some() {
975 tracing::debug!("Using cached map tile");
976 } else {
977 tracing::debug!("Using cached map tile absence");
978 }
979 let outcome = if cached_map_tile.is_none() {
980 TileOutcome::Missing
981 } else if memory_cache_hit {
982 TileOutcome::LoadedFromMemoryCache
983 } else {
984 TileOutcome::LoadedFromDiskCache
985 };
986 return Ok((cached_map_tile, outcome));
987 }
988 tracing::debug!("Map tile cache not fresh, removing from cache");
989 self.remove_cached_tile(map_tile_descriptor).await?;
990 }
991 tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
992 if let Some(ratelimiter) = &self.ratelimiter {
993 while let Err(err) = ratelimiter.try_wait() {
994 match err {
995 ratelimit::TryWaitError::Insufficient(duration) => {
996 tokio::time::sleep(duration).await;
997 }
998 _ => {
999 return Err(MapTileCacheError::RatelimiterError(err));
1000 }
1001 }
1002 }
1003 }
1004 tracing::debug!("Fetching map tile from server at {}", url);
1005 let response = self
1006 .client
1007 .execute(
1008 request
1009 .try_clone()
1010 .ok_or(MapTileCacheError::FailedToCloneRequest)?,
1011 )
1012 .await?;
1013 tracing::debug!(
1014 "Server response received: status {}, headers\n{:#?}",
1015 response.status(),
1016 response.headers()
1017 );
1018 if !response.status().is_success() {
1019 if response.status() == reqwest::StatusCode::FORBIDDEN {
1020 tracing::debug!(
1023 "Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
1024 );
1025 let cache_policy = http_cache_semantics::CachePolicy::new(
1026 &request,
1027 &MapTileNegativeResponse(response),
1028 );
1029 self.cache_missing_tile(map_tile_descriptor, cache_policy)
1030 .await?;
1031 return Ok((None, TileOutcome::Missing));
1032 }
1033 return Err(MapTileCacheError::HttpError(
1034 url.to_owned(),
1035 response.status(),
1036 response.headers().to_owned(),
1037 response.text().await?,
1038 ));
1039 }
1040 let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
1041 let raw_response_body = response.bytes().await?;
1042 tracing::debug!("Parsing received map tile to image");
1043 let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
1044 .with_guessed_format()
1045 .map_err(MapTileCacheError::ImageFormatGuessError)?
1046 .decode()?;
1047 let map_tile = MapTile {
1048 descriptor: map_tile_descriptor.to_owned(),
1049 image,
1050 };
1051 self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
1052 .await?;
1053 tracing::debug!("Returning freshly fetched map tile");
1054 Ok((Some(map_tile), TileOutcome::FetchedFromNetwork))
1055 }
1056
1057 pub async fn does_map_tile_exist(
1064 &mut self,
1065 map_tile_descriptor: &MapTileDescriptor,
1066 ) -> Result<bool, MapTileCacheError> {
1067 let url = self.map_tile_url(map_tile_descriptor);
1068 if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
1069 let request = self.client.get(&url).build()?;
1070 let now = std::time::SystemTime::now();
1071 if let http_cache_semantics::BeforeRequest::Fresh(_) =
1072 cache_policy.before_request(&request, now)
1073 {
1074 return Ok(map_tile.is_some());
1075 }
1076 }
1077 if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
1078 && let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
1079 {
1080 let request = self.client.get(&url).build()?;
1081 let now = std::time::SystemTime::now();
1082 if let http_cache_semantics::BeforeRequest::Fresh(_) =
1083 cache_policy.before_request(&request, now)
1084 {
1085 if self
1086 .map_tile_cache_negative_response_file_name(map_tile_descriptor)
1087 .exists()
1088 {
1089 return Ok(false);
1090 }
1091 return Ok(true);
1092 }
1093 }
1094 Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
1095 }
1096
1097 pub async fn does_region_exist(
1104 &mut self,
1105 grid_coordinates: &GridCoordinates,
1106 ) -> Result<bool, MapTileCacheError> {
1107 for zoom_level in (1..=8).rev() {
1108 tracing::debug!(
1109 "Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
1110 );
1111 let map_tile_descriptor = MapTileDescriptor::new(
1112 ZoomLevel::try_new(zoom_level)?,
1113 grid_coordinates.to_owned(),
1114 );
1115 if !self.does_map_tile_exist(&map_tile_descriptor).await? {
1116 tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
1117 return Ok(false);
1118 }
1119 let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
1120 if cache_entry_status == MapTileCacheEntryStatus::Valid {}
1121 }
1122 tracing::debug!(
1123 "Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
1124 );
1125 Ok(true)
1126 }
1127}
1128
1129#[derive(Debug, Clone)]
1131pub struct Map {
1132 zoom_level: ZoomLevel,
1134 grid_rectangle: GridRectangle,
1136 image: image::DynamicImage,
1138}
1139
1140#[derive(Debug, thiserror::Error)]
1142pub enum MapError {
1143 #[error("error in map tile cache while assembling map: {0}")]
1145 MapTileCacheError(#[from] MapTileCacheError),
1146 #[error(
1149 "error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
1150 )]
1151 ZoomFitError(#[from] ZoomFitError),
1152 #[error("error when cropping a map tile to the required size")]
1154 MapTileCropError,
1155 #[error("error when calculating pixel coordinates where we want to place a map tile crop")]
1157 MapCoordinateError,
1158 #[error("no overlap between map tile we fetched and output map (should not happen)")]
1160 NoOverlapError,
1161 #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
1164 NoGridCoordinatesForRegion(RegionName),
1165 #[error("error in region name to grid coordinate cache: {0}")]
1167 RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
1168 #[error("error calculating spline: {0}")]
1170 SplineError(
1171 #[source]
1172 #[from]
1173 uniform_cubic_splines::SplineError,
1174 ),
1175}
1176
1177impl Map {
1178 pub async fn new(
1198 map_tile_cache: &mut MapTileCache,
1199 x: u32,
1200 y: u32,
1201 grid_rectangle: GridRectangle,
1202 fill_missing_map_tiles: Option<image::Rgba<u8>>,
1203 fill_missing_regions: Option<image::Rgba<u8>>,
1204 ) -> Result<Self, MapError> {
1205 Self::new_with_progress(
1206 map_tile_cache,
1207 x,
1208 y,
1209 grid_rectangle,
1210 fill_missing_map_tiles,
1211 fill_missing_regions,
1212 None,
1213 )
1214 .await
1215 }
1216
1217 pub async fn new_with_progress(
1228 map_tile_cache: &mut MapTileCache,
1229 x: u32,
1230 y: u32,
1231 grid_rectangle: GridRectangle,
1232 fill_missing_map_tiles: Option<image::Rgba<u8>>,
1233 fill_missing_regions: Option<image::Rgba<u8>>,
1234 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1235 ) -> Result<Self, MapError> {
1236 let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1237 grid_rectangle.size_x(),
1238 grid_rectangle.size_y(),
1239 x,
1240 y,
1241 )?;
1242 let actual_x =
1243 <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_x();
1244 let actual_y =
1245 <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_y();
1246 tracing::debug!(
1247 "Determined max zoom level for map of size ({x}, {y}) for {grid_rectangle:?} to be {zoom_level:?}, actual map size will be ({actual_x}, {actual_y})"
1248 );
1249 let x = actual_x;
1250 let y = actual_y;
1251 let image = image::DynamicImage::new_rgb8(x, y);
1252 let mut result = Self {
1253 zoom_level,
1254 grid_rectangle,
1255 image,
1256 };
1257 let mut total_tiles: u32 = 0;
1260 for region_x in result.x_range() {
1261 for region_y in result.y_range() {
1262 let grid_coordinates = GridCoordinates::new(region_x, region_y);
1263 let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1264 let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1265 return Err(MapError::NoOverlapError);
1266 };
1267 if overlap.lower_left_corner().x() == region_x
1268 && overlap.lower_left_corner().y() == region_y
1269 {
1270 total_tiles = total_tiles.saturating_add(1);
1271 }
1272 }
1273 }
1274 emit_progress(
1275 progress,
1276 MapProgressEvent::PlanComputed {
1277 zoom_level,
1278 total_tiles,
1279 },
1280 );
1281 if fill_missing_regions.is_some() {
1282 let total_regions: u32 = result.size_x().saturating_mul(result.size_y());
1287 emit_progress(
1288 progress,
1289 MapProgressEvent::RegionCheckPlanned { total_regions },
1290 );
1291 }
1292 for region_x in result.x_range() {
1293 for region_y in result.y_range() {
1294 let grid_coordinates = GridCoordinates::new(region_x, region_y);
1295 let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
1296 let Some(overlap) = result.intersect(&map_tile_descriptor) else {
1297 return Err(MapError::NoOverlapError);
1298 };
1299 if overlap.lower_left_corner().x() != region_x
1300 || overlap.lower_left_corner().y() != region_y
1301 {
1302 continue;
1305 }
1306 tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
1307 emit_progress(
1308 progress,
1309 MapProgressEvent::TileStarted {
1310 descriptor: map_tile_descriptor.to_owned(),
1311 },
1312 );
1313 let (fetched_tile, outcome) = map_tile_cache
1314 .get_map_tile_with_outcome(&map_tile_descriptor)
1315 .await?;
1316 emit_progress(
1317 progress,
1318 MapProgressEvent::TileFinished {
1319 descriptor: map_tile_descriptor.to_owned(),
1320 outcome,
1321 },
1322 );
1323 if let Some(map_tile) = fetched_tile {
1324 let crop = map_tile
1325 .crop_imm_grid_rectangle(&overlap)
1326 .ok_or(MapError::MapTileCropError)?;
1327 tracing::debug!(
1328 "Cropped map tile to ({}, {})+{}x{}",
1329 crop.offsets().0,
1330 crop.offsets().1,
1331 (*crop).dimensions().0,
1332 (*crop).dimensions().1
1333 );
1334 let (replace_x, replace_y) = result
1337 .pixel_coordinates_for_coordinates(
1338 &overlap.upper_left_corner(),
1339 &RegionCoordinates::new(0f32, 256f32, 0f32),
1340 )
1341 .ok_or(MapError::MapCoordinateError)?;
1342 tracing::debug!(
1343 "Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
1344 );
1345 image::imageops::replace(
1346 &mut result,
1347 &*crop,
1348 replace_x.into(),
1349 replace_y.into(),
1350 );
1351 if let Some(fill_color) = fill_missing_regions {
1352 for overlap_region_x in overlap.x_range() {
1353 for overlap_region_y in overlap.y_range() {
1354 let grid_coordinates =
1355 GridCoordinates::new(overlap_region_x, overlap_region_y);
1356 let exists =
1357 map_tile_cache.does_region_exist(&grid_coordinates).await?;
1358 emit_progress(
1359 progress,
1360 MapProgressEvent::RegionChecked {
1361 x: overlap_region_x,
1362 y: overlap_region_y,
1363 exists,
1364 },
1365 );
1366 if !exists {
1367 let pixel_min = result.pixel_coordinates_for_coordinates(
1368 &grid_coordinates,
1369 &RegionCoordinates::new(0f32, 256f32, 0f32),
1370 );
1371 let pixel_max = result.pixel_coordinates_for_coordinates(
1372 &grid_coordinates,
1373 &RegionCoordinates::new(256f32, 0f32, 0f32),
1374 );
1375 if let (Some((min_x, min_y)), Some((max_x, max_y))) =
1376 (pixel_min, pixel_max)
1377 {
1378 for x in min_x..max_x {
1379 for y in min_y..max_y {
1380 <Self as image::GenericImage>::put_pixel(
1381 &mut result,
1382 x,
1383 y,
1384 fill_color,
1385 );
1386 }
1387 }
1388 }
1389 }
1390 }
1391 }
1392 }
1393 } else if let Some(fill_color) = fill_missing_map_tiles {
1394 let (replace_x, replace_y) = result
1395 .pixel_coordinates_for_coordinates(
1396 &overlap.upper_left_corner(),
1397 &RegionCoordinates::new(0f32, 256f32, 0f32),
1398 )
1399 .ok_or(MapError::MapCoordinateError)?;
1400 let pixel_size_x = overlap.size_x() * u32::from(zoom_level.pixels_per_region());
1401 let pixel_size_y = overlap.size_y() * u32::from(zoom_level.pixels_per_region());
1402 for x in replace_x..replace_x + pixel_size_x {
1403 for y in replace_y..replace_y + pixel_size_y {
1404 <Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
1405 }
1406 }
1407 }
1408 }
1409 }
1410 Ok(result)
1411 }
1412
1413 pub async fn draw_route(
1420 &mut self,
1421 region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1422 usb_notecard: &USBNotecard,
1423 color: image::Rgba<u8>,
1424 ) -> Result<(), MapError> {
1425 self.draw_route_with_progress(
1426 region_name_to_grid_coordinates_cache,
1427 usb_notecard,
1428 color,
1429 None,
1430 )
1431 .await
1432 }
1433
1434 pub async fn draw_route_with_progress(
1444 &mut self,
1445 region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
1446 usb_notecard: &USBNotecard,
1447 color: image::Rgba<u8>,
1448 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
1449 ) -> Result<(), MapError> {
1450 tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
1451 let waypoints = usb_notecard.waypoints();
1452 let total_waypoints = waypoints.len();
1453 emit_progress(progress, MapProgressEvent::RoutePlanned { total_waypoints });
1454 let mut pixel_waypoints = Vec::new();
1455 for (index, waypoint) in waypoints.iter().enumerate() {
1456 let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
1457 .get_grid_coordinates(waypoint.location().region_name())
1458 .await?
1459 else {
1460 return Err(MapError::NoGridCoordinatesForRegion(
1461 waypoint.location().region_name().to_owned(),
1462 ));
1463 };
1464 emit_progress(
1465 progress,
1466 MapProgressEvent::RouteWaypointResolved {
1467 index,
1468 total: total_waypoints,
1469 region: waypoint.location().region_name().to_owned(),
1470 },
1471 );
1472 let (x, y) = self
1473 .pixel_coordinates_for_coordinates(
1474 &grid_coordinates,
1475 &waypoint.region_coordinates(),
1476 )
1477 .ok_or(MapError::MapCoordinateError)?;
1478 tracing::debug!(
1479 "Drawing waypoint at ({x}, {y}) for location {:?}",
1480 waypoint.location()
1481 );
1482 #[expect(
1484 clippy::cast_precision_loss,
1485 reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
1486 )]
1487 pixel_waypoints.push((x as f32, y as f32));
1488 }
1489 self.draw_pixel_waypoint_route(&pixel_waypoints, color)?;
1490 Ok(())
1491 }
1492
1493 pub fn draw_pixel_waypoint_route(
1510 &mut self,
1511 pixel_waypoints: &[(f32, f32)],
1512 color: image::Rgba<u8>,
1513 ) -> Result<(), uniform_cubic_splines::SplineError> {
1514 let waypoint_count = pixel_waypoints.len();
1515 let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
1516 return Ok(());
1518 };
1519 let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
1520 else {
1521 return Ok(());
1523 };
1524 let extra_before_start = (
1525 first.0 - (second.0 - first.0),
1526 first.1 - (second.1 - first.1),
1527 );
1528 let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
1529 return Ok(());
1531 };
1532 let Some((second_to_last, _pixel_waypoints_rest)) =
1533 pixel_waypoints_all_but_last.split_last()
1534 else {
1535 return Ok(());
1537 };
1538 let extra_after_end = (
1539 last.0 + (last.0 - second_to_last.0),
1540 last.1 + (last.1 - second_to_last.1),
1541 );
1542 let mut knots = vec![extra_before_start];
1543 knots.extend(pixel_waypoints.to_owned());
1544 knots.push(extra_after_end);
1545 let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
1546 let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
1547 let point_x =
1548 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1549 v, &points_x,
1550 )?;
1551 let point_y =
1552 uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
1553 v, &points_y,
1554 )?;
1555 Ok((point_x, point_y))
1556 };
1557 #[expect(
1564 clippy::cast_precision_loss,
1565 reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1566 )]
1567 let waypoint_parameter_denominator = if waypoint_count <= 2 {
1568 (waypoint_count as f32 - 1f32).max(1f32)
1569 } else {
1570 waypoint_count as f32 - 2f32
1571 };
1572 let spline_value_for_waypoint = |i: usize| -> f32 {
1573 #[expect(
1574 clippy::cast_precision_loss,
1575 reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
1576 )]
1577 let i = i as f32;
1578 i / waypoint_parameter_denominator
1579 };
1580 let spline_value_between_waypoints = spline_value_for_waypoint(1);
1581 let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
1582 ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
1583 };
1584 let mut last_point: Option<(f32, f32)> = None;
1585 let outer_loop_count = if waypoint_count <= 2 {
1590 waypoint_count
1591 } else {
1592 waypoint_count - 1
1593 };
1594 for (i, waypoint) in pixel_waypoints.iter().enumerate().take(outer_loop_count) {
1595 const SPLINE_RECT_SIZE: u8 = 3;
1598 tracing::debug!("Waypoint {}: {:?}", i, waypoint);
1599 let v = spline_value_for_waypoint(i);
1600 let point = sample(v)?;
1601 tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
1602 if let Some(last_point) = last_point {
1603 let distance_from_last_point = distance_between_points(point, last_point);
1604 tracing::debug!(
1605 "Waypoint {i} is {:?} from last waypoint",
1606 distance_from_last_point
1607 );
1608 #[expect(
1609 clippy::cast_possible_truncation,
1610 reason = "we want an integer count for the number of samples"
1611 )]
1612 #[expect(
1613 clippy::cast_sign_loss,
1614 reason = "we want a positive count for the number of samples"
1615 )]
1616 let samples_between_last_waypoint_and_this_one =
1617 (0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
1618 #[expect(
1626 clippy::cast_precision_loss,
1627 reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1628 )]
1629 let sample_step_denominator =
1630 (samples_between_last_waypoint_and_this_one as f32 - 2f32).max(1f32);
1631 for j in (0..samples_between_last_waypoint_and_this_one).rev() {
1632 #[expect(
1633 clippy::cast_precision_loss,
1634 reason = "if our waypoints are so far apart that we end up with 2^23 or more samples between two waypoints something is very broken anyway"
1635 )]
1636 let v =
1637 v - spline_value_between_waypoints * (j as f32 / sample_step_denominator);
1638 let sample_point = sample(v)?;
1639 #[expect(
1640 clippy::cast_possible_truncation,
1641 reason = "we want integer pixel coordinates for use in the image library"
1642 )]
1643 imageproc::drawing::draw_filled_rect_mut(
1644 self.image_mut(),
1645 imageproc::rect::Rect::at(
1646 sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1647 sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
1648 )
1649 .of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
1650 color,
1651 );
1652 }
1653 self.draw_arrow(
1654 sample(v - (0.1f32 * spline_value_between_waypoints))?,
1655 point,
1656 color,
1657 );
1658 }
1659 last_point = Some(point);
1660 }
1661 Ok(())
1662 }
1663
1664 #[must_use]
1676 pub fn blank(grid_rectangle: GridRectangle, zoom_level: ZoomLevel) -> Self {
1677 let width =
1678 <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_x();
1679 let height =
1680 <u16 as Into<u32>>::into(zoom_level.pixels_per_region()) * grid_rectangle.size_y();
1681 Self {
1682 zoom_level,
1683 grid_rectangle,
1684 image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1685 }
1686 }
1687
1688 #[expect(
1701 clippy::result_large_err,
1702 reason = "returns the same large MapError as the other Map constructors for a consistent error type at call sites; the only failure here is the zoom-fit calculation"
1703 )]
1704 pub fn blank_fit(
1705 grid_rectangle: GridRectangle,
1706 max_width: u32,
1707 max_height: u32,
1708 ) -> Result<Self, MapError> {
1709 let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
1710 grid_rectangle.size_x(),
1711 grid_rectangle.size_y(),
1712 max_width,
1713 max_height,
1714 )?;
1715 Ok(Self::blank(grid_rectangle, zoom_level))
1716 }
1717
1718 #[cfg(test)]
1722 fn new_blank_for_test(width: u32, height: u32) -> Self {
1723 #[expect(
1724 clippy::expect_used,
1725 reason = "zoom level 1 is a compile-time constant within the valid range"
1726 )]
1727 let zoom_level = ZoomLevel::try_new(1).expect("zoom level 1 is within the valid range");
1728 Self {
1729 zoom_level,
1730 grid_rectangle: GridRectangle::new(
1731 GridCoordinates::new(0, 0),
1732 GridCoordinates::new(0, 0),
1733 ),
1734 image: image::DynamicImage::ImageRgba8(image::RgbaImage::new(width, height)),
1735 }
1736 }
1737
1738 pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
1745 self.image.save(path)
1746 }
1747}
1748
1749impl GridRectangleLike for Map {
1750 fn grid_rectangle(&self) -> GridRectangle {
1751 self.grid_rectangle.to_owned()
1752 }
1753}
1754
1755impl image::GenericImageView for Map {
1756 type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
1757
1758 fn dimensions(&self) -> (u32, u32) {
1759 self.image.dimensions()
1760 }
1761
1762 fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
1763 self.image.get_pixel(x, y)
1764 }
1765}
1766
1767impl image::GenericImage for Map {
1768 fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
1769 #[expect(
1770 deprecated,
1771 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1772 )]
1773 self.image.get_pixel_mut(x, y)
1774 }
1775
1776 fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1777 self.image.put_pixel(x, y, pixel);
1778 }
1779
1780 fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
1781 #[expect(
1782 deprecated,
1783 reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
1784 )]
1785 self.image.blend_pixel(x, y, pixel);
1786 }
1787}
1788
1789impl MapLike for Map {
1790 fn zoom_level(&self) -> ZoomLevel {
1791 self.zoom_level
1792 }
1793
1794 fn image(&self) -> &image::DynamicImage {
1795 &self.image
1796 }
1797
1798 fn image_mut(&mut self) -> &mut image::DynamicImage {
1799 &mut self.image
1800 }
1801}
1802
1803#[cfg(test)]
1804mod test {
1805 use image::GenericImageView as _;
1806 use tracing_test::traced_test;
1807
1808 use super::*;
1809
1810 #[test]
1811 fn test_map_tile_url_default_base() -> Result<(), Box<dyn std::error::Error>> {
1812 let temp_dir = tempfile::tempdir()?;
1813 let map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1814 let url = map_tile_cache.map_tile_url(&MapTileDescriptor::new(
1815 ZoomLevel::try_new(2)?,
1816 GridCoordinates::new(1000, 1001),
1817 ));
1818 assert_eq!(
1819 url,
1820 "https://secondlife-maps-cdn.akamaized.net/map-2-1000-1000-objects.jpg"
1821 );
1822 Ok(())
1823 }
1824
1825 #[test]
1826 fn test_map_tile_url_custom_base_without_trailing_slash()
1827 -> Result<(), Box<dyn std::error::Error>> {
1828 let temp_dir = tempfile::tempdir()?;
1829 let map_tile_cache = MapTileCache::new_with_base_url(
1830 temp_dir.path().to_path_buf(),
1831 None,
1832 "http://127.0.0.1:9000".to_owned(),
1833 );
1834 let url = map_tile_cache.map_tile_url(&MapTileDescriptor::new(
1835 ZoomLevel::try_new(1)?,
1836 GridCoordinates::new(1000, 1000),
1837 ));
1838 assert_eq!(url, "http://127.0.0.1:9000/map-1-1000-1000-objects.jpg");
1839 Ok(())
1840 }
1841
1842 #[tokio::test]
1843 async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
1844 let temp_dir = tempfile::tempdir()?;
1845 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1846 map_tile_cache
1847 .get_map_tile(&MapTileDescriptor::new(
1848 ZoomLevel::try_new(1)?,
1849 GridCoordinates::new(1136, 1075),
1850 ))
1851 .await?;
1852 Ok(())
1853 }
1854
1855 #[tokio::test]
1856 async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
1857 let temp_dir = tempfile::tempdir()?;
1858 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1859 map_tile_cache
1860 .get_map_tile(&MapTileDescriptor::new(
1861 ZoomLevel::try_new(1)?,
1862 GridCoordinates::new(1136, 1075),
1863 ))
1864 .await?;
1865 map_tile_cache
1866 .get_map_tile(&MapTileDescriptor::new(
1867 ZoomLevel::try_new(1)?,
1868 GridCoordinates::new(1136, 1075),
1869 ))
1870 .await?;
1871 Ok(())
1872 }
1873
1874 #[tokio::test]
1875 async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
1876 let temp_dir = tempfile::tempdir()?;
1877 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1878 map_tile_cache
1879 .get_map_tile(&MapTileDescriptor::new(
1880 ZoomLevel::try_new(8)?,
1881 GridCoordinates::new(1136, 1075),
1882 ))
1883 .await?;
1884 Ok(())
1885 }
1886
1887 #[traced_test]
1888 #[tokio::test]
1889 async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
1890 let temp_dir = tempfile::tempdir()?;
1891 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1892 let mut map_tile_cache =
1893 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1894 let map = Map::new(
1895 &mut map_tile_cache,
1896 512,
1897 512,
1898 GridRectangle::new(
1899 GridCoordinates::new(1135, 1070),
1900 GridCoordinates::new(1136, 1071),
1901 ),
1902 None,
1903 None,
1904 )
1905 .await?;
1906 map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
1907 Ok(())
1908 }
1909
1910 #[traced_test]
1911 #[tokio::test]
1912 async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
1913 let temp_dir = tempfile::tempdir()?;
1914 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1915 let mut map_tile_cache =
1916 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1917 let map = Map::new(
1918 &mut map_tile_cache,
1919 256,
1920 256,
1921 GridRectangle::new(
1922 GridCoordinates::new(1136, 1074),
1923 GridCoordinates::new(1137, 1075),
1924 ),
1925 None,
1926 None,
1927 )
1928 .await?;
1929 map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
1930 Ok(())
1931 }
1932
1933 #[traced_test]
1934 #[tokio::test]
1935 async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
1936 let temp_dir = tempfile::tempdir()?;
1937 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1938 let mut map_tile_cache =
1939 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1940 let map = Map::new(
1941 &mut map_tile_cache,
1942 128,
1943 128,
1944 GridRectangle::new(
1945 GridCoordinates::new(1136, 1074),
1946 GridCoordinates::new(1137, 1075),
1947 ),
1948 None,
1949 None,
1950 )
1951 .await?;
1952 map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
1953 Ok(())
1954 }
1955
1956 #[traced_test]
1957 #[tokio::test]
1958 async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
1959 let temp_dir = tempfile::tempdir()?;
1960 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
1961 let mut map_tile_cache =
1962 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
1963 let map = Map::new(
1964 &mut map_tile_cache,
1965 2048,
1966 2048,
1967 GridRectangle::new(
1968 GridCoordinates::new(1131, 1068),
1969 GridCoordinates::new(1139, 1075),
1970 ),
1971 None,
1972 None,
1973 )
1974 .await?;
1975 map.save(std::path::Path::new(
1976 "/tmp/test_map_zoom_level_1_ratelimiter.jpg",
1977 ))?;
1978 Ok(())
1979 }
1980
1981 #[traced_test]
1982 #[tokio::test]
1983 #[expect(clippy::panic, reason = "panic in test is intentional")]
1984 async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
1985 -> Result<(), Box<dyn std::error::Error>> {
1986 let temp_dir = tempfile::tempdir()?;
1987 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
1988 let Some(map_tile) = map_tile_cache
1989 .get_map_tile(&MapTileDescriptor::new(
1990 ZoomLevel::try_new(1)?,
1991 GridCoordinates::new(1136, 1075),
1992 ))
1993 .await?
1994 else {
1995 panic!("Expected there to be a region at this location");
1996 };
1997 for in_region_x in 0..=256 {
1998 for in_region_y in 0..=256 {
1999 let grid_coordinates = GridCoordinates::new(1136, 1075);
2000 #[expect(
2001 clippy::cast_precision_loss,
2002 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
2003 )]
2004 let region_coordinates =
2005 RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
2006 tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
2007 assert_eq!(
2008 map_tile
2009 .pixel_coordinates_for_coordinates(&grid_coordinates, ®ion_coordinates,),
2010 Some((in_region_x, 256 - in_region_y)),
2011 );
2012 }
2013 }
2014 Ok(())
2015 }
2016
2017 #[traced_test]
2018 #[tokio::test]
2019 async fn test_map_pixel_coordinates_for_coordinates_four_regions()
2020 -> Result<(), Box<dyn std::error::Error>> {
2021 let temp_dir = tempfile::tempdir()?;
2022 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
2023 let mut map_tile_cache =
2024 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
2025 let map = Map::new(
2026 &mut map_tile_cache,
2027 512,
2028 512,
2029 GridRectangle::new(
2030 GridCoordinates::new(1136, 1074),
2031 GridCoordinates::new(1137, 1075),
2032 ),
2033 None,
2034 None,
2035 )
2036 .await?;
2037 for region_offset_x in 0u16..=1 {
2038 for region_offset_y in 0u16..=1 {
2039 for in_region_x in 0u16..=256 {
2040 for in_region_y in 0u16..=256 {
2041 let grid_coordinates = GridCoordinates::new(
2042 u32::from(1136 + region_offset_x),
2043 u32::from(1074 + region_offset_y),
2044 );
2045 let region_coordinates = RegionCoordinates::new(
2046 f32::from(in_region_x),
2047 f32::from(in_region_y),
2048 0f32,
2049 );
2050 tracing::debug!(
2051 "Now checking {grid_coordinates:?}, {region_coordinates:?}"
2052 );
2053 assert_eq!(
2054 map.pixel_coordinates_for_coordinates(
2055 &grid_coordinates,
2056 ®ion_coordinates,
2057 ),
2058 Some((
2059 u32::from(region_offset_x * 256 + in_region_x),
2060 u32::from(512 - (region_offset_y * 256 + in_region_y))
2061 )),
2062 );
2063 }
2064 }
2065 }
2066 }
2067 Ok(())
2068 }
2069
2070 #[traced_test]
2071 #[tokio::test]
2072 #[expect(clippy::panic, reason = "panic in test is intentional")]
2073 async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
2074 -> Result<(), Box<dyn std::error::Error>> {
2075 let temp_dir = tempfile::tempdir()?;
2076 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2077 let Some(map_tile) = map_tile_cache
2078 .get_map_tile(&MapTileDescriptor::new(
2079 ZoomLevel::try_new(1)?,
2080 GridCoordinates::new(1136, 1075),
2081 ))
2082 .await?
2083 else {
2084 panic!("Expected there to be a region at this location");
2085 };
2086 tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
2087 #[expect(
2088 clippy::cast_precision_loss,
2089 reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
2090 )]
2091 for in_region_x in 0..=256 {
2092 for in_region_y in 0..=256 {
2093 let pixel_x = in_region_x;
2094 let pixel_y = 256 - in_region_y;
2095 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2096 assert_eq!(
2097 map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2098 Some((
2099 GridCoordinates::new(
2100 1136 + if in_region_x == 256 { 1 } else { 0 },
2101 1075 + if in_region_y == 256 { 1 } else { 0 }
2102 ),
2103 RegionCoordinates::new(
2104 (in_region_x % 256) as f32,
2105 (in_region_y % 256) as f32,
2106 0f32
2107 ),
2108 ))
2109 );
2110 }
2111 }
2112 Ok(())
2113 }
2114
2115 #[traced_test]
2116 #[tokio::test]
2117 async fn test_map_coordinates_for_pixel_coordinates_four_regions()
2118 -> Result<(), Box<dyn std::error::Error>> {
2119 let temp_dir = tempfile::tempdir()?;
2120 let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
2121 let mut map_tile_cache =
2122 MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
2123 let map = Map::new(
2124 &mut map_tile_cache,
2125 512,
2126 512,
2127 GridRectangle::new(
2128 GridCoordinates::new(1136, 1074),
2129 GridCoordinates::new(1137, 1075),
2130 ),
2131 None,
2132 None,
2133 )
2134 .await?;
2135 tracing::debug!("Dimensions of map are {:?}", map.dimensions());
2136 for region_offset_x in 0u16..=1 {
2137 for region_offset_y in 0u16..=1 {
2138 for in_region_x in 0u16..=256 {
2139 for in_region_y in 0u16..=256 {
2140 let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
2141 let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
2142 tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
2143 assert_eq!(
2144 map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
2145 Some((
2146 GridCoordinates::new(
2147 u32::from(
2148 1136 + region_offset_x
2149 + if in_region_x == 256 { 1 } else { 0 }
2150 ),
2151 u32::from(
2152 1074 + region_offset_y
2153 + if in_region_y == 256 { 1 } else { 0 }
2154 )
2155 ),
2156 RegionCoordinates::new(
2157 f32::from(in_region_x % 256),
2158 f32::from(in_region_y % 256),
2159 0f32
2160 ),
2161 )),
2162 );
2163 }
2164 }
2165 }
2166 }
2167 Ok(())
2168 }
2169
2170 #[cfg(test)]
2172 const BLANK_PIXEL: [u8; 4] = [0, 0, 0, 0];
2173
2174 #[cfg(test)]
2176 fn drawn_pixel_count(map: &Map) -> usize {
2177 map.image()
2178 .pixels()
2179 .filter(|(_, _, pixel)| pixel.0 != BLANK_PIXEL)
2180 .count()
2181 }
2182
2183 #[test]
2188 fn test_draw_route_identical_consecutive_waypoints_is_safe()
2189 -> Result<(), Box<dyn std::error::Error>> {
2190 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2191 let mut map = Map::new_blank_for_test(256, 256);
2192 let pixel_waypoints = vec![
2194 (100f32, 100f32),
2195 (120f32, 120f32),
2196 (120f32, 120f32),
2197 (140f32, 100f32),
2198 ];
2199 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2200 assert!(
2201 drawn_pixel_count(&map) > 0,
2202 "the route should still draw at least one pixel"
2203 );
2204 assert_eq!(
2205 map.image().get_pixel(0, 0).0,
2206 BLANK_PIXEL,
2207 "the (0,0) corner must stay background (no NaN-derived rectangle)"
2208 );
2209 Ok(())
2210 }
2211
2212 #[test]
2216 fn test_draw_route_two_waypoints_renders() -> Result<(), Box<dyn std::error::Error>> {
2217 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2218 let mut map = Map::new_blank_for_test(256, 256);
2219 let pixel_waypoints = vec![(60f32, 60f32), (180f32, 180f32)];
2220 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2221 assert!(
2222 drawn_pixel_count(&map) > 0,
2223 "a two-waypoint route must draw a visible curve (regression for the waypoint_count - 2 == 0 bug)"
2224 );
2225 assert_eq!(
2226 map.image().get_pixel(0, 0).0,
2227 BLANK_PIXEL,
2228 "the (0,0) corner must stay background"
2229 );
2230 Ok(())
2231 }
2232
2233 #[test]
2238 fn test_draw_route_two_sample_segment_has_no_corner_glitch()
2239 -> Result<(), Box<dyn std::error::Error>> {
2240 let route_color = image::Rgba([255u8, 0u8, 0u8, 255u8]);
2241 let mut map = Map::new_blank_for_test(256, 256);
2242 let pixel_waypoints = vec![(100f32, 100f32), (110f32, 110f32)];
2243 map.draw_pixel_waypoint_route(&pixel_waypoints, route_color)?;
2244 assert_eq!(
2245 map.image().get_pixel(0, 0).0,
2246 BLANK_PIXEL,
2247 "samples == 2 must not draw a NaN-derived rectangle at the (0,0) corner"
2248 );
2249 Ok(())
2250 }
2251
2252 #[test]
2256 fn test_real_notecard_with_duplicate_consecutive_lines_parses()
2257 -> Result<(), Box<dyn std::error::Error>> {
2258 let notecard: USBNotecard =
2259 include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2260 let has_identical_consecutive = notecard
2261 .waypoints()
2262 .windows(2)
2263 .any(|pair| matches!(pair, [first, second] if first.location() == second.location()));
2264 assert!(
2265 has_identical_consecutive,
2266 "tscc-2026-03-30.txt is expected to contain identical consecutive waypoints"
2267 );
2268 Ok(())
2269 }
2270
2271 #[tokio::test]
2274 async fn test_draw_real_route_with_duplicate_line() -> Result<(), Box<dyn std::error::Error>> {
2275 let notecard: USBNotecard =
2276 include_str!("../tests/fixtures/tscc-2026-03-30.txt").parse()?;
2277 let temp_dir = tempfile::tempdir()?;
2278 let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
2279 let mut region_cache =
2280 RegionNameToGridCoordinatesCache::new(temp_dir.path().to_path_buf())?;
2281 let grid_rectangle =
2282 crate::region::usb_notecard_to_grid_rectangle(&mut region_cache, ¬ecard).await?;
2283 let mut map = Map::new(&mut map_tile_cache, 512, 512, grid_rectangle, None, None).await?;
2284 map.draw_route_with_progress(
2285 &mut region_cache,
2286 ¬ecard,
2287 image::Rgba([255u8, 0u8, 0u8, 255u8]),
2288 None,
2289 )
2290 .await?;
2291 Ok(())
2292 }
2293}