use std::path::PathBuf;
use image::GenericImageView as _;
use sl_types::map::{
GridCoordinateOffset, GridCoordinates, GridRectangle, GridRectangleLike, MapTileDescriptor,
RegionCoordinates, RegionName, USBNotecard, ZoomFitError, ZoomLevel, ZoomLevelError,
};
use crate::region::RegionNameToGridCoordinatesCache;
pub trait MapLike: GridRectangleLike + image::GenericImage + image::GenericImageView {
#[must_use]
fn image(&self) -> &image::DynamicImage;
#[must_use]
fn image_mut(&mut self) -> &mut image::DynamicImage;
#[must_use]
fn zoom_level(&self) -> ZoomLevel;
#[must_use]
fn pixels_per_meter(&self) -> f32 {
self.zoom_level().pixels_per_meter()
}
#[must_use]
fn pixels_per_region(&self) -> f32 {
self.pixels_per_meter() * 256f32
}
#[must_use]
fn pixel_coordinates_for_coordinates(
&self,
grid_coordinates: &GridCoordinates,
region_coordinates: &RegionCoordinates,
) -> Option<(u32, u32)> {
if !self.contains(grid_coordinates) {
return None;
}
#[expect(
clippy::arithmetic_side_effects,
reason = "this should never underflow since we already checked with contains that the grid coordinates are inside the map"
)]
let grid_offset = *grid_coordinates - self.lower_left_corner();
#[expect(
clippy::cast_possible_truncation,
reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
)]
#[expect(
clippy::cast_precision_loss,
reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
)]
#[expect(
clippy::cast_sign_loss,
reason = "Since grid_offset is the difference between the lower left corner and a coordinate inside the map it is always positive"
)]
#[expect(
clippy::as_conversions,
reason = "For the reasons mentioned in the other expects this should be safe here"
)]
let x = (self.pixels_per_region() * grid_offset.x() as f32
+ self.pixels_per_meter() * region_coordinates.x()) as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
)]
#[expect(
clippy::cast_precision_loss,
reason = "since we are dealing with image sizes here the numbers never get anywhere near the maximum values of either type"
)]
#[expect(
clippy::cast_sign_loss,
reason = "Since grid_offset is the difference between the lower left corner and a coordinate inside the map it is always positive"
)]
#[expect(
clippy::as_conversions,
reason = "For the reasons mentioned in the other expects this should be safe here"
)]
let y = (self.pixels_per_region() * grid_offset.y() as f32
+ self.pixels_per_meter() * region_coordinates.y()) as u32;
#[expect(
clippy::arithmetic_side_effects,
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"
)]
let y = self.height() - y;
Some((x, y))
}
#[must_use]
fn coordinates_for_pixel_coordinates(
&self,
x: u32,
y: u32,
) -> Option<(GridCoordinates, RegionCoordinates)> {
if !(x <= self.width() && y <= self.height()) {
return None;
}
#[expect(
clippy::arithmetic_side_effects,
reason = "we just checked that y is less than or equal to height so this can not underflow"
)]
let y = self.height() - y;
#[expect(
clippy::arithmetic_side_effects,
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"
)]
#[expect(
clippy::cast_possible_truncation,
reason = "we are dealing with grid coordinates so integers are fine"
)]
#[expect(
clippy::cast_precision_loss,
reason = "our pixel coordinates are not going to be anywhere near 2^23 or we should rethink our choices of types anyway"
)]
let grid_result = self.lower_left_corner()
+ GridCoordinateOffset::new(
(x as f32 / self.pixels_per_region()) as i32,
(y as f32 / self.pixels_per_region()) as i32,
);
#[expect(
clippy::cast_possible_truncation,
reason = "pixels_per_region are always an integer, even if they are represented as f32"
)]
#[expect(
clippy::cast_sign_loss,
reason = "pixels_per_region is always positive"
)]
#[expect(
clippy::cast_precision_loss,
reason = "x % pixels_per_region should be no larger than 255 (the largest pixels_per_region value is 256)"
)]
let region_result = RegionCoordinates::new(
(x % self.pixels_per_region() as u32) as f32 / self.pixels_per_meter(),
(y % self.pixels_per_region() as u32) as f32 / self.pixels_per_meter(),
0f32,
);
Some((grid_result, region_result))
}
#[must_use]
fn crop_imm_grid_rectangle(
&self,
grid_rectangle: &GridRectangle,
) -> Option<image::SubImage<&Self>>
where
Self: Sized,
{
let lower_left_corner_pixels = self.pixel_coordinates_for_coordinates(
&grid_rectangle.lower_left_corner(),
&RegionCoordinates::new(0f32, 0f32, 0f32),
)?;
let upper_right_corner_pixels = self.pixel_coordinates_for_coordinates(
&grid_rectangle.upper_right_corner(),
&RegionCoordinates::new(256f32, 256f32, 0f32),
)?;
let x = std::cmp::min(lower_left_corner_pixels.0, upper_right_corner_pixels.0);
let y = std::cmp::min(lower_left_corner_pixels.1, upper_right_corner_pixels.1);
let width = lower_left_corner_pixels
.0
.abs_diff(upper_right_corner_pixels.0);
let height = lower_left_corner_pixels
.1
.abs_diff(upper_right_corner_pixels.1);
Some(image::imageops::crop_imm(self, x, y, width, height))
}
fn draw_waypoint(&mut self, x: u32, y: u32, color: image::Rgba<u8>) {
#[expect(
clippy::cast_possible_wrap,
reason = "our pixel coordinates should be nowhere near i32::MAX"
)]
imageproc::drawing::draw_filled_rect_mut(
self.image_mut(),
imageproc::rect::Rect::at(x as i32 - 5i32, y as i32 - 5i32).of_size(10, 10),
color,
);
}
fn draw_line(
&mut self,
from_x: u32,
from_y: u32,
to_x: u32,
to_y: u32,
color: image::Rgba<u8>,
) {
if from_x == to_x && from_y == to_y {
return;
}
#[expect(
clippy::cast_precision_loss,
reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
)]
let from_x = from_x as f32;
#[expect(
clippy::cast_precision_loss,
reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
)]
let from_y = from_y as f32;
#[expect(
clippy::cast_precision_loss,
reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
)]
let to_x = to_x as f32;
#[expect(
clippy::cast_precision_loss,
reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
)]
let to_y = to_y as f32;
let diff = (to_x - from_x, to_y - from_y);
let perpendicular = (-diff.1, diff.0);
let magnitude = (diff.0.powi(2) + diff.1.powi(2)).sqrt();
let perpendicular_normalized = (perpendicular.0 / magnitude, perpendicular.1 / magnitude);
#[expect(
clippy::cast_possible_truncation,
reason = "we want integer coordinates for use in Points"
)]
let points = vec![
imageproc::point::Point::new(
(from_x + perpendicular_normalized.0 * 5.0) as i32,
(from_y + perpendicular_normalized.1 * 5.0) as i32,
),
imageproc::point::Point::new(
(to_x + perpendicular_normalized.0 * 5.0) as i32,
(to_y + perpendicular_normalized.1 * 5.0) as i32,
),
imageproc::point::Point::new(
(to_x - perpendicular_normalized.0 * 5.0) as i32,
(to_y - perpendicular_normalized.1 * 5.0) as i32,
),
imageproc::point::Point::new(
(from_x - perpendicular_normalized.0 * 5.0) as i32,
(from_y - perpendicular_normalized.1 * 5.0) as i32,
),
];
imageproc::drawing::draw_antialiased_polygon_mut(
self.image_mut(),
&points,
color,
imageproc::pixelops::interpolate,
);
}
fn draw_arrow(&mut self, from: (f32, f32), tip: (f32, f32), color: image::Rgba<u8>) {
const ARROW_LENGTH: f32 = 15f32;
const ARROW_HALF_WIDTH: f32 = 5f32;
if from == tip {
return;
}
let arrow_direction = (tip.0 - from.0, tip.1 - from.1);
let arrow_direction_magnitude =
(arrow_direction.0.powf(2f32) + arrow_direction.1.powf(2f32)).sqrt();
let arrow_direction = (
arrow_direction.0 / arrow_direction_magnitude,
arrow_direction.1 / arrow_direction_magnitude,
);
let arrow_base_middle = (
tip.0 - (ARROW_LENGTH * arrow_direction.0),
tip.1 - (ARROW_LENGTH * arrow_direction.1),
);
let arrow_base_side1 = (
arrow_base_middle.0 + (ARROW_HALF_WIDTH * arrow_direction.1),
arrow_base_middle.1 - (ARROW_HALF_WIDTH * arrow_direction.0),
);
let arrow_base_side2 = (
arrow_base_middle.0 - (ARROW_HALF_WIDTH * arrow_direction.1),
arrow_base_middle.1 + (ARROW_HALF_WIDTH * arrow_direction.0),
);
tracing::debug!(
"Painting arrow with arrow direction {:?}, arrow tip {:?}, arrow base middle {:?}, arrow_base_side1 {:?}, arrow_base_side2 {:?} ",
arrow_direction,
tip,
arrow_base_middle,
arrow_base_side1,
arrow_base_side2
);
#[expect(
clippy::cast_possible_truncation,
reason = "we want integer coordinates for use in Points"
)]
imageproc::drawing::draw_polygon_mut(
self.image_mut(),
&[
imageproc::point::Point::new(arrow_base_side1.0 as i32, arrow_base_side1.1 as i32),
imageproc::point::Point::new(tip.0 as i32, tip.1 as i32),
imageproc::point::Point::new(arrow_base_side2.0 as i32, arrow_base_side2.1 as i32),
],
color,
);
}
}
#[derive(Debug, Clone)]
pub struct MapTile {
descriptor: MapTileDescriptor,
image: image::DynamicImage,
}
impl MapTile {
#[must_use]
pub const fn descriptor(&self) -> &MapTileDescriptor {
&self.descriptor
}
}
impl GridRectangleLike for MapTile {
fn grid_rectangle(&self) -> GridRectangle {
self.descriptor.grid_rectangle()
}
}
impl image::GenericImageView for MapTile {
type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
fn dimensions(&self) -> (u32, u32) {
self.image.dimensions()
}
fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
self.image.get_pixel(x, y)
}
}
impl image::GenericImage for MapTile {
fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
#[expect(
deprecated,
reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
)]
self.image.get_pixel_mut(x, y)
}
fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
self.image.put_pixel(x, y, pixel);
}
fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
#[expect(
deprecated,
reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
)]
self.image.blend_pixel(x, y, pixel);
}
}
impl MapLike for MapTile {
fn zoom_level(&self) -> ZoomLevel {
self.descriptor.zoom_level().to_owned()
}
fn image(&self) -> &image::DynamicImage {
&self.image
}
fn image_mut(&mut self) -> &mut image::DynamicImage {
&mut self.image
}
}
#[derive(Debug, thiserror::Error)]
pub enum MapTileCacheError {
#[error("error manipulating files in the cache directory: {0}")]
CacheDirectoryFileError(std::io::Error),
#[error("reqwest error when fetching the map tile from the server: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("HTTP request is not success: URL {0} response status {1} headers {2:#?} body {3}")]
HttpError(
String,
reqwest::StatusCode,
reqwest::header::HeaderMap,
String,
),
#[error("failed to clone request for cache policy")]
FailedToCloneRequest,
#[error("error guessing image format: {0}")]
ImageFormatGuessError(std::io::Error),
#[error("error reading the raw map tile into an image: {0}")]
ImageError(#[from] image::ImageError),
#[error("error decoding the JSON serialized CachePolicy: {0}")]
CachePolicyJsonDecodeError(#[from] serde_json::Error),
#[error("error creating a zoom level: {0}")]
ZoomLevelError(#[from] ZoomLevelError),
#[error("error when trying to load cache policy that we previously checked existed on disk")]
CachePolicyError,
}
#[derive(derive_more::Debug)]
pub struct MapTileCache {
client: reqwest::Client,
#[debug(skip)]
ratelimiter: Option<ratelimit::Ratelimiter>,
cache_directory: PathBuf,
#[debug(skip)]
cache: lru::LruCache<MapTileDescriptor, (Option<MapTile>, http_cache_semantics::CachePolicy)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MapTileCacheEntryStatus {
Missing,
Invalid,
Valid,
}
#[derive(Debug)]
pub struct MapTileNegativeResponse(reqwest::Response);
impl http_cache_semantics::ResponseLike for MapTileNegativeResponse {
fn status(&self) -> http::status::StatusCode {
match self.0.status() {
http::status::StatusCode::FORBIDDEN => http::status::StatusCode::NOT_FOUND,
status => status,
}
}
fn headers(&self) -> &http::header::HeaderMap {
self.0.headers()
}
}
impl MapTileCache {
#[expect(clippy::missing_panics_doc, reason = "we know 16 is non-zero")]
#[must_use]
pub fn new(cache_directory: PathBuf, ratelimiter: Option<ratelimit::Ratelimiter>) -> Self {
#[expect(clippy::unwrap_used, reason = "we know 16 is non-zero")]
let cache = lru::LruCache::new(std::num::NonZeroUsize::new(16).unwrap());
Self {
client: reqwest::Client::new(),
ratelimiter,
cache_directory,
cache,
}
}
#[must_use]
fn map_tile_file_name(map_tile_descriptor: &MapTileDescriptor) -> String {
format!(
"map-{}-{}-{}-objects.jpg",
map_tile_descriptor.zoom_level(),
map_tile_descriptor.lower_left_corner().x(),
map_tile_descriptor.lower_left_corner().y(),
)
}
#[must_use]
fn map_tile_cache_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
self.cache_directory
.join(Self::map_tile_file_name(map_tile_descriptor))
}
#[must_use]
fn map_tile_cache_negative_response_file_name(
&self,
map_tile_descriptor: &MapTileDescriptor,
) -> PathBuf {
self.cache_directory.join(format!(
"{}.does-not-exist",
Self::map_tile_file_name(map_tile_descriptor)
))
}
#[must_use]
fn cache_policy_file_name(&self, map_tile_descriptor: &MapTileDescriptor) -> PathBuf {
self.cache_directory.join(format!(
"{}.cache-policy.json",
Self::map_tile_file_name(map_tile_descriptor)
))
}
#[must_use]
fn map_tile_url(map_tile_descriptor: &MapTileDescriptor) -> String {
format!(
"https://secondlife-maps-cdn.akamaized.net/{}",
Self::map_tile_file_name(map_tile_descriptor),
)
}
async fn cache_entry_status(
&self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<MapTileCacheEntryStatus, MapTileCacheError> {
match (
self.cache_policy_file_name(map_tile_descriptor).exists(),
self.map_tile_cache_file_name(map_tile_descriptor).exists(),
self.map_tile_cache_negative_response_file_name(map_tile_descriptor)
.exists(),
) {
(false, false, false) => Ok(MapTileCacheEntryStatus::Missing),
(true, true, false) | (true, false, true) => Ok(MapTileCacheEntryStatus::Valid),
(cp, tile, neg) => {
tracing::warn!(
"cache entry status is invalid: cache policy file: {}, map tile file: {}, negative response file: {}",
cp,
tile,
neg
);
Ok(MapTileCacheEntryStatus::Invalid)
}
}
}
async fn fetch_cached_map_tile(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<Option<(Option<MapTile>, http_cache_semantics::CachePolicy)>, MapTileCacheError>
{
if let Some(cache_entry) = self.cache.get(map_tile_descriptor) {
return Ok(Some(cache_entry.to_owned()));
}
let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
let cache_entry_status = self.cache_entry_status(map_tile_descriptor).await?;
if cache_entry_status == MapTileCacheEntryStatus::Invalid {
self.remove_cached_tile(map_tile_descriptor).await?;
return Ok(None);
}
if cache_entry_status == MapTileCacheEntryStatus::Missing {
return Ok(None);
}
let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await? else {
return Err(MapTileCacheError::CachePolicyError);
};
if cache_file.exists() {
let cached_map_tile = image::ImageReader::open(cache_file)
.map_err(MapTileCacheError::CacheDirectoryFileError)?
.decode()?;
Ok(Some((
Some(MapTile {
descriptor: map_tile_descriptor.to_owned(),
image: cached_map_tile,
}),
cache_policy,
)))
} else {
Ok(Some((None, cache_policy)))
}
}
async fn remove_cached_tile(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<(), MapTileCacheError> {
tracing::debug!("Removing {map_tile_descriptor:?} from map tile cache");
self.cache.pop(map_tile_descriptor);
let cache_file = self.map_tile_cache_file_name(map_tile_descriptor);
let cache_file_negative_response =
self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
if cache_file.exists() {
std::fs::remove_file(cache_file).map_err(MapTileCacheError::CacheDirectoryFileError)?;
}
if cache_file_negative_response.exists() {
std::fs::remove_file(cache_file_negative_response)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
}
if cache_policy_file.exists() {
std::fs::remove_file(cache_policy_file)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
}
Ok(())
}
async fn load_cache_policy(
&self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<Option<http_cache_semantics::CachePolicy>, MapTileCacheError> {
let cache_policy_file = self.cache_policy_file_name(map_tile_descriptor);
if !cache_policy_file.exists() {
return Ok(None);
}
let cache_policy = std::fs::read_to_string(cache_policy_file)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
Ok(serde_json::from_str(&cache_policy)?)
}
async fn store_cache_policy(
&self,
map_tile_descriptor: &MapTileDescriptor,
cache_policy: http_cache_semantics::CachePolicy,
) -> Result<(), MapTileCacheError> {
if !self.cache_directory.exists() {
std::fs::create_dir_all(&self.cache_directory)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
}
let cache_policy = serde_json::to_string(&cache_policy)?;
std::fs::write(
self.cache_policy_file_name(map_tile_descriptor),
cache_policy,
)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
Ok(())
}
async fn cache_missing_tile(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
cache_policy: http_cache_semantics::CachePolicy,
) -> Result<(), MapTileCacheError> {
if cache_policy.is_storable() {
tracing::debug!("Caching absence of map tile {map_tile_descriptor:?}");
self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
.await?;
let cache_file_negative_response =
self.map_tile_cache_negative_response_file_name(map_tile_descriptor);
std::fs::File::create(cache_file_negative_response)
.map_err(MapTileCacheError::CacheDirectoryFileError)?;
self.cache
.put(map_tile_descriptor.clone(), (None, cache_policy));
} else {
tracing::warn!(
"Absence of map tile {map_tile_descriptor:?} not storable according to cache policy"
);
}
Ok(())
}
async fn cache_tile(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
map_tile: &MapTile,
cache_policy: http_cache_semantics::CachePolicy,
) -> Result<(), MapTileCacheError> {
if cache_policy.is_storable() {
tracing::debug!("Caching map tile {map_tile_descriptor:?}");
self.store_cache_policy(map_tile_descriptor, cache_policy.to_owned())
.await?;
map_tile
.image
.save(self.map_tile_cache_file_name(map_tile_descriptor))?;
self.cache.put(
map_tile_descriptor.clone(),
(Some(map_tile.to_owned()), cache_policy),
);
} else {
tracing::warn!(
"Map tile {map_tile_descriptor:?} not storable according to cache policy"
);
}
Ok(())
}
pub async fn get_map_tile(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<Option<MapTile>, MapTileCacheError> {
tracing::debug!("Map tile {map_tile_descriptor:?} requested");
let url = Self::map_tile_url(map_tile_descriptor);
let request = self.client.get(&url).build()?;
let now = std::time::SystemTime::now();
if let Some((cached_map_tile, cache_policy)) =
self.fetch_cached_map_tile(map_tile_descriptor).await?
{
if cached_map_tile.is_some() {
tracing::debug!("Found matching map tile in cache, checking freshness");
} else {
tracing::debug!("Found matching map tile absence in cache, checking freshness");
}
if let http_cache_semantics::BeforeRequest::Fresh(_) =
cache_policy.before_request(&request, now)
{
if cached_map_tile.is_some() {
tracing::debug!("Using cached map tile");
} else {
tracing::debug!("Using cached map tile absence");
}
return Ok(cached_map_tile);
}
tracing::debug!("Map tile cache not fresh, removing from cache");
self.remove_cached_tile(map_tile_descriptor).await?;
}
tracing::debug!("Waiting for ratelimiter to fetch map tile from server");
if let Some(ratelimiter) = &self.ratelimiter {
while let Err(duration) = ratelimiter.try_wait() {
tokio::time::sleep(duration).await;
}
}
tracing::debug!("Fetching map tile from server at {}", url);
let response = self
.client
.execute(
request
.try_clone()
.ok_or(MapTileCacheError::FailedToCloneRequest)?,
)
.await?;
tracing::debug!(
"Server response received: status {}, headers\n{:#?}",
response.status(),
response.headers()
);
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::FORBIDDEN {
tracing::debug!(
"Received 403 FORBIDDEN response, interpreting as no map tile for these grid coordinates"
);
let cache_policy = http_cache_semantics::CachePolicy::new(
&request,
&MapTileNegativeResponse(response),
);
self.cache_missing_tile(map_tile_descriptor, cache_policy)
.await?;
return Ok(None);
}
return Err(MapTileCacheError::HttpError(
url.to_owned(),
response.status(),
response.headers().to_owned(),
response.text().await?,
));
}
let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
let raw_response_body = response.bytes().await?;
tracing::debug!("Parsing received map tile to image");
let image = image::ImageReader::new(std::io::Cursor::new(raw_response_body))
.with_guessed_format()
.map_err(MapTileCacheError::ImageFormatGuessError)?
.decode()?;
let map_tile = MapTile {
descriptor: map_tile_descriptor.to_owned(),
image,
};
self.cache_tile(map_tile_descriptor, &map_tile, cache_policy)
.await?;
tracing::debug!("Returning freshly fetched map tile");
Ok(Some(map_tile))
}
pub async fn does_map_tile_exist(
&mut self,
map_tile_descriptor: &MapTileDescriptor,
) -> Result<bool, MapTileCacheError> {
let url = Self::map_tile_url(map_tile_descriptor);
if let Some((map_tile, cache_policy)) = self.cache.get(map_tile_descriptor) {
let request = self.client.get(&url).build()?;
let now = std::time::SystemTime::now();
if let http_cache_semantics::BeforeRequest::Fresh(_) =
cache_policy.before_request(&request, now)
{
return Ok(map_tile.is_some());
}
}
if self.cache_entry_status(map_tile_descriptor).await? == MapTileCacheEntryStatus::Valid
&& let Some(cache_policy) = self.load_cache_policy(map_tile_descriptor).await?
{
let request = self.client.get(&url).build()?;
let now = std::time::SystemTime::now();
if let http_cache_semantics::BeforeRequest::Fresh(_) =
cache_policy.before_request(&request, now)
{
if self
.map_tile_cache_negative_response_file_name(map_tile_descriptor)
.exists()
{
return Ok(false);
}
return Ok(true);
}
}
Ok(self.get_map_tile(map_tile_descriptor).await?.is_some())
}
pub async fn does_region_exist(
&mut self,
grid_coordinates: &GridCoordinates,
) -> Result<bool, MapTileCacheError> {
for zoom_level in (1..=8).rev() {
tracing::debug!(
"Checking if zoom level {zoom_level} map tile exists for region {grid_coordinates:?}"
);
let map_tile_descriptor = MapTileDescriptor::new(
ZoomLevel::try_new(zoom_level)?,
grid_coordinates.to_owned(),
);
if !self.does_map_tile_exist(&map_tile_descriptor).await? {
tracing::debug!("No map tile found, region {grid_coordinates:?} does not exist");
return Ok(false);
}
let cache_entry_status = self.cache_entry_status(&map_tile_descriptor).await?;
if cache_entry_status == MapTileCacheEntryStatus::Valid {}
}
tracing::debug!(
"Map tiles exist for {grid_coordinates:?} on all zoom levels, region exists"
);
Ok(true)
}
}
#[derive(Debug, Clone)]
pub struct Map {
zoom_level: ZoomLevel,
grid_rectangle: GridRectangle,
image: image::DynamicImage,
}
#[derive(Debug, thiserror::Error)]
pub enum MapError {
#[error("error in map tile cache while assembling map: {0}")]
MapTileCacheError(#[from] MapTileCacheError),
#[error(
"error when trying to calculate zoom level that fits the map grid rectangle into the output image: {0}"
)]
ZoomFitError(#[from] ZoomFitError),
#[error("error when cropping a map tile to the required size")]
MapTileCropError,
#[error("error when calculating pixel coordinates where we want to place a map tile crop")]
MapCoordinateError,
#[error("no overlap between map tile we fetched and output map (should not happen)")]
NoOverlapError,
#[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
NoGridCoordinatesForRegion(RegionName),
#[error("error in region name to grid coordinate cache: {0}")]
RegionNameToGridCoordinateCacheError(#[from] crate::region::CacheError),
#[error("error calculating spline: {0}")]
SplineError(
#[source]
#[from]
uniform_cubic_splines::SplineError,
),
}
impl Map {
pub async fn new(
map_tile_cache: &mut MapTileCache,
x: u32,
y: u32,
grid_rectangle: GridRectangle,
fill_missing_map_tiles: Option<image::Rgba<u8>>,
fill_missing_regions: Option<image::Rgba<u8>>,
) -> Result<Self, MapError> {
let zoom_level = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(
grid_rectangle.size_x(),
grid_rectangle.size_y(),
x,
y,
)?;
let actual_x = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
* <u16 as Into<u32>>::into(grid_rectangle.size_x());
let actual_y = <u16 as Into<u32>>::into(zoom_level.pixels_per_region())
* <u16 as Into<u32>>::into(grid_rectangle.size_y());
tracing::debug!(
"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})"
);
let x = actual_x;
let y = actual_y;
let image = image::DynamicImage::new_rgb8(x, y);
let mut result = Self {
zoom_level,
grid_rectangle,
image,
};
for region_x in result.x_range() {
for region_y in result.y_range() {
let grid_coordinates = GridCoordinates::new(region_x, region_y);
let map_tile_descriptor = MapTileDescriptor::new(zoom_level, grid_coordinates);
let Some(overlap) = result.intersect(&map_tile_descriptor) else {
return Err(MapError::NoOverlapError);
};
if overlap.lower_left_corner().x() != region_x
|| overlap.lower_left_corner().y() != region_y
{
continue;
}
tracing::debug!("Map tile for {grid_coordinates:?} is {map_tile_descriptor:?}");
if let Some(map_tile) = map_tile_cache.get_map_tile(&map_tile_descriptor).await? {
let crop = map_tile
.crop_imm_grid_rectangle(&overlap)
.ok_or(MapError::MapTileCropError)?;
tracing::debug!(
"Cropped map tile to ({}, {})+{}x{}",
crop.offsets().0,
crop.offsets().1,
(*crop).dimensions().0,
(*crop).dimensions().1
);
let (replace_x, replace_y) = result
.pixel_coordinates_for_coordinates(
&overlap.upper_left_corner(),
&RegionCoordinates::new(0f32, 256f32, 0f32),
)
.ok_or(MapError::MapCoordinateError)?;
tracing::debug!(
"Placing map tile crop at ({replace_x}, {replace_y}) in the output image"
);
image::imageops::replace(
&mut result,
&*crop,
replace_x.into(),
replace_y.into(),
);
if let Some(fill_color) = fill_missing_regions {
for overlap_region_x in overlap.x_range() {
for overlap_region_y in overlap.y_range() {
let grid_coordinates =
GridCoordinates::new(overlap_region_x, overlap_region_y);
if !map_tile_cache.does_region_exist(&grid_coordinates).await? {
let pixel_min = result.pixel_coordinates_for_coordinates(
&grid_coordinates,
&RegionCoordinates::new(0f32, 256f32, 0f32),
);
let pixel_max = result.pixel_coordinates_for_coordinates(
&grid_coordinates,
&RegionCoordinates::new(256f32, 0f32, 0f32),
);
if let (Some((min_x, min_y)), Some((max_x, max_y))) =
(pixel_min, pixel_max)
{
for x in min_x..max_x {
for y in min_y..max_y {
<Self as image::GenericImage>::put_pixel(
&mut result,
x,
y,
fill_color,
);
}
}
}
}
}
}
}
} else if let Some(fill_color) = fill_missing_map_tiles {
let (replace_x, replace_y) = result
.pixel_coordinates_for_coordinates(
&overlap.upper_left_corner(),
&RegionCoordinates::new(0f32, 256f32, 0f32),
)
.ok_or(MapError::MapCoordinateError)?;
let pixel_size_x =
u32::from(overlap.size_x()) * u32::from(zoom_level.pixels_per_region());
let pixel_size_y =
u32::from(overlap.size_y()) * u32::from(zoom_level.pixels_per_region());
for x in replace_x..replace_x + pixel_size_x {
for y in replace_y..replace_y + pixel_size_y {
<Self as image::GenericImage>::put_pixel(&mut result, x, y, fill_color);
}
}
}
}
}
Ok(result)
}
pub async fn draw_route(
&mut self,
region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
usb_notecard: &USBNotecard,
color: image::Rgba<u8>,
) -> Result<(), MapError> {
tracing::debug!("Drawing route:\n{:#?}", usb_notecard);
let mut pixel_waypoints = Vec::new();
for waypoint in usb_notecard.waypoints() {
let Some(grid_coordinates) = region_name_to_grid_coordinates_cache
.get_grid_coordinates(waypoint.location().region_name())
.await?
else {
return Err(MapError::NoGridCoordinatesForRegion(
waypoint.location().region_name().to_owned(),
));
};
let (x, y) = self
.pixel_coordinates_for_coordinates(
&grid_coordinates,
&waypoint.region_coordinates(),
)
.ok_or(MapError::MapCoordinateError)?;
tracing::debug!(
"Drawing waypoint at ({x}, {y}) for location {:?}",
waypoint.location()
);
#[expect(
clippy::cast_precision_loss,
reason = "if our pixel coordinates get anywhere near 2^23 we probably should reconsider all types anyway"
)]
pixel_waypoints.push((x as f32, y as f32));
}
let waypoint_count = pixel_waypoints.len();
let Some((first, pixel_waypoints_all_but_first)) = pixel_waypoints.split_first() else {
return Ok(());
};
let Some((second, _pixel_waypoints_rest)) = pixel_waypoints_all_but_first.split_first()
else {
return Ok(());
};
let extra_before_start = (
first.0 - (second.0 - first.0),
first.1 - (second.1 - first.1),
);
let Some((last, pixel_waypoints_all_but_last)) = pixel_waypoints.split_last() else {
return Ok(());
};
let Some((second_to_last, _pixel_waypoints_rest)) =
pixel_waypoints_all_but_last.split_last()
else {
return Ok(());
};
let extra_after_end = (
last.0 + (last.0 - second_to_last.0),
last.1 + (last.1 - second_to_last.1),
);
let mut knots = vec![extra_before_start];
knots.extend(pixel_waypoints.to_owned());
knots.push(extra_after_end);
let (points_x, points_y): (Vec<f32>, Vec<f32>) = knots.into_iter().unzip();
let sample = |v: f32| -> Result<(f32, f32), uniform_cubic_splines::SplineError> {
let point_x =
uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
v, &points_x,
)?;
let point_y =
uniform_cubic_splines::spline::<uniform_cubic_splines::basis::CatmullRom, _, _>(
v, &points_y,
)?;
Ok((point_x, point_y))
};
#[expect(
clippy::cast_precision_loss,
reason = "if our waypoint counts get anywhere near 2^23 routes probably will not be finished anyway"
)]
let spline_value_for_waypoint =
|i: usize| -> f32 { i as f32 / (waypoint_count as f32 - 2f32) };
let spline_value_between_waypoints = spline_value_for_waypoint(1);
let distance_between_points = |(x1, y1): (f32, f32), (x2, y2): (f32, f32)| -> f32 {
((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt()
};
let mut last_point: Option<(f32, f32)> = None;
for (i, waypoint) in pixel_waypoints.iter().enumerate().take(waypoint_count - 1) {
const SPLINE_RECT_SIZE: u8 = 3;
tracing::debug!("Waypoint {}: {:?}", i, waypoint);
let v = spline_value_for_waypoint(i);
let point = sample(v)?;
tracing::debug!("Sampled Catmull Rom curve {i} at point {v}: {point:?} for route");
if let Some(last_point) = last_point {
let distance_from_last_point = distance_between_points(point, last_point);
tracing::debug!(
"Waypoint {i} is {:?} from last waypoint",
distance_from_last_point
);
#[expect(
clippy::cast_possible_truncation,
reason = "we want an integer count for the number of samples"
)]
#[expect(
clippy::cast_sign_loss,
reason = "we want a positive count for the number of samples"
)]
let samples_between_last_waypoint_and_this_one =
(0.5f32 * distance_from_last_point / f32::from(SPLINE_RECT_SIZE)) as u32;
for j in (0..samples_between_last_waypoint_and_this_one).rev() {
#[expect(
clippy::cast_precision_loss,
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"
)]
let v = v - spline_value_between_waypoints
* (j as f32 / (samples_between_last_waypoint_and_this_one as f32 - 2f32));
let sample_point = sample(v)?;
#[expect(
clippy::cast_possible_truncation,
reason = "we want integer pixel coordinates for use in the image library"
)]
imageproc::drawing::draw_filled_rect_mut(
self.image_mut(),
imageproc::rect::Rect::at(
sample_point.0 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
sample_point.1 as i32 - ((i32::from(SPLINE_RECT_SIZE) - 1) / 2),
)
.of_size(u32::from(SPLINE_RECT_SIZE), u32::from(SPLINE_RECT_SIZE)),
color,
);
}
self.draw_arrow(
sample(v - (0.1f32 * spline_value_between_waypoints))?,
point,
color,
);
}
last_point = Some(point);
}
Ok(())
}
pub fn save(&self, path: &std::path::Path) -> Result<(), image::ImageError> {
self.image.save(path)
}
}
impl GridRectangleLike for Map {
fn grid_rectangle(&self) -> GridRectangle {
self.grid_rectangle.to_owned()
}
}
impl image::GenericImageView for Map {
type Pixel = <image::DynamicImage as image::GenericImageView>::Pixel;
fn dimensions(&self) -> (u32, u32) {
self.image.dimensions()
}
fn get_pixel(&self, x: u32, y: u32) -> Self::Pixel {
self.image.get_pixel(x, y)
}
}
impl image::GenericImage for Map {
fn get_pixel_mut(&mut self, x: u32, y: u32) -> &mut Self::Pixel {
#[expect(
deprecated,
reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
)]
self.image.get_pixel_mut(x, y)
}
fn put_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
self.image.put_pixel(x, y, pixel);
}
fn blend_pixel(&mut self, x: u32, y: u32, pixel: Self::Pixel) {
#[expect(
deprecated,
reason = "we need to use this deprecated function to implement the deprecated function when passing it through"
)]
self.image.blend_pixel(x, y, pixel);
}
}
impl MapLike for Map {
fn zoom_level(&self) -> ZoomLevel {
self.zoom_level
}
fn image(&self) -> &image::DynamicImage {
&self.image
}
fn image_mut(&mut self) -> &mut image::DynamicImage {
&mut self.image
}
}
#[cfg(test)]
mod test {
use image::GenericImageView as _;
use tracing_test::traced_test;
use super::*;
#[tokio::test]
async fn test_fetch_map_tile_highest_detail() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(1)?,
GridCoordinates::new(1136, 1075),
))
.await?;
Ok(())
}
#[tokio::test]
async fn test_fetch_map_tile_highest_detail_twice() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(1)?,
GridCoordinates::new(1136, 1075),
))
.await?;
map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(1)?,
GridCoordinates::new(1136, 1075),
))
.await?;
Ok(())
}
#[tokio::test]
async fn test_fetch_map_tile_lowest_detail() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(8)?,
GridCoordinates::new(1136, 1075),
))
.await?;
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_fetch_map_zoom_level_1() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
512,
512,
GridRectangle::new(
GridCoordinates::new(1135, 1070),
GridCoordinates::new(1136, 1071),
),
None,
None,
)
.await?;
map.save(std::path::Path::new("/tmp/test_map_zoom_level_1.jpg"))?;
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_fetch_map_zoom_level_2() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
256,
256,
GridRectangle::new(
GridCoordinates::new(1136, 1074),
GridCoordinates::new(1137, 1075),
),
None,
None,
)
.await?;
map.save(std::path::Path::new("/tmp/test_map_zoom_level_2.jpg"))?;
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_fetch_map_zoom_level_3() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
128,
128,
GridRectangle::new(
GridCoordinates::new(1136, 1074),
GridCoordinates::new(1137, 1075),
),
None,
None,
)
.await?;
map.save(std::path::Path::new("/tmp/test_map_zoom_level_3.jpg"))?;
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_fetch_map_zoom_level_1_ratelimiter() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
2048,
2048,
GridRectangle::new(
GridCoordinates::new(1131, 1068),
GridCoordinates::new(1139, 1075),
),
None,
None,
)
.await?;
map.save(std::path::Path::new(
"/tmp/test_map_zoom_level_1_ratelimiter.jpg",
))?;
Ok(())
}
#[traced_test]
#[tokio::test]
#[expect(clippy::panic, reason = "panic in test is intentional")]
async fn test_map_tile_pixel_coordinates_for_coordinates_single_region()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
let Some(map_tile) = map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(1)?,
GridCoordinates::new(1136, 1075),
))
.await?
else {
panic!("Expected there to be a region at this location");
};
for in_region_x in 0..=256 {
for in_region_y in 0..=256 {
let grid_coordinates = GridCoordinates::new(1136, 1075);
#[expect(
clippy::cast_precision_loss,
reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
)]
let region_coordinates =
RegionCoordinates::new(in_region_x as f32, in_region_y as f32, 0f32);
tracing::debug!("Now checking {grid_coordinates:?}, {region_coordinates:?}");
assert_eq!(
map_tile
.pixel_coordinates_for_coordinates(&grid_coordinates, ®ion_coordinates,),
Some((in_region_x, 256 - in_region_y)),
);
}
}
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_map_pixel_coordinates_for_coordinates_four_regions()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
512,
512,
GridRectangle::new(
GridCoordinates::new(1136, 1074),
GridCoordinates::new(1137, 1075),
),
None,
None,
)
.await?;
for region_offset_x in 0..=1 {
for region_offset_y in 0..=1 {
for in_region_x in 0..=256 {
for in_region_y in 0..=256 {
let grid_coordinates =
GridCoordinates::new(1136 + region_offset_x, 1074 + region_offset_y);
let region_coordinates = RegionCoordinates::new(
f32::from(in_region_x),
f32::from(in_region_y),
0f32,
);
tracing::debug!(
"Now checking {grid_coordinates:?}, {region_coordinates:?}"
);
assert_eq!(
map.pixel_coordinates_for_coordinates(
&grid_coordinates,
®ion_coordinates,
),
Some((
u32::from(region_offset_x * 256 + in_region_x),
u32::from(512 - (region_offset_y * 256 + in_region_y))
)),
);
}
}
}
}
Ok(())
}
#[traced_test]
#[tokio::test]
#[expect(clippy::panic, reason = "panic in test is intentional")]
async fn test_map_tile_coordinates_for_pixel_coordinates_single_region()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut map_tile_cache = MapTileCache::new(temp_dir.path().to_path_buf(), None);
let Some(map_tile) = map_tile_cache
.get_map_tile(&MapTileDescriptor::new(
ZoomLevel::try_new(1)?,
GridCoordinates::new(1136, 1075),
))
.await?
else {
panic!("Expected there to be a region at this location");
};
tracing::debug!("Dimensions of map tile are {:?}", map_tile.dimensions());
#[expect(
clippy::cast_precision_loss,
reason = "in_region_x and in_region_y are between 0 and 256, nowhere near 2^23"
)]
for in_region_x in 0..=256 {
for in_region_y in 0..=256 {
let pixel_x = in_region_x;
let pixel_y = 256 - in_region_y;
tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
assert_eq!(
map_tile.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
Some((
GridCoordinates::new(
1136 + if in_region_x == 256 { 1 } else { 0 },
1075 + if in_region_y == 256 { 1 } else { 0 }
),
RegionCoordinates::new(
(in_region_x % 256) as f32,
(in_region_y % 256) as f32,
0f32
),
))
);
}
}
Ok(())
}
#[traced_test]
#[tokio::test]
async fn test_map_coordinates_for_pixel_coordinates_four_regions()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let ratelimiter = ratelimit::Ratelimiter::builder(1).build()?;
let mut map_tile_cache =
MapTileCache::new(temp_dir.path().to_path_buf(), Some(ratelimiter));
let map = Map::new(
&mut map_tile_cache,
512,
512,
GridRectangle::new(
GridCoordinates::new(1136, 1074),
GridCoordinates::new(1137, 1075),
),
None,
None,
)
.await?;
tracing::debug!("Dimensions of map are {:?}", map.dimensions());
for region_offset_x in 0..=1 {
for region_offset_y in 0..=1 {
for in_region_x in 0..=256 {
for in_region_y in 0..=256 {
let pixel_x = u32::from(region_offset_x * 256 + in_region_x);
let pixel_y = u32::from(512 - (region_offset_y * 256 + in_region_y));
tracing::debug!("Now checking ({pixel_x}, {pixel_y})");
assert_eq!(
map.coordinates_for_pixel_coordinates(pixel_x, pixel_y,),
Some((
GridCoordinates::new(
1136 + region_offset_x + if in_region_x == 256 { 1 } else { 0 },
1074 + region_offset_y + if in_region_y == 256 { 1 } else { 0 }
),
RegionCoordinates::new(
f32::from(in_region_x % 256),
f32::from(in_region_y % 256),
0f32
),
)),
);
}
}
}
}
Ok(())
}
}