#[cfg(feature = "chumsky")]
use chumsky::{
IterParser as _, Parser,
prelude::{any, just},
text::whitespace,
};
#[cfg(feature = "chumsky")]
use crate::utils::{
f32_parser, i16_parser, i32_parser, u8_parser, u16_parser, url_text_component_parser,
};
#[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
pub struct Distance(f64);
impl std::fmt::Display for Distance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} m", self.0)
}
}
impl std::ops::Add for Distance {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl std::ops::Sub for Distance {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl std::ops::Mul<u8> for Distance {
type Output = Self;
fn mul(self, rhs: u8) -> Self::Output {
Self(self.0 * f64::from(rhs))
}
}
impl std::ops::Mul<u16> for Distance {
type Output = Self;
fn mul(self, rhs: u16) -> Self::Output {
Self(self.0 * f64::from(rhs))
}
}
impl std::ops::Mul<u32> for Distance {
type Output = Self;
fn mul(self, rhs: u32) -> Self::Output {
Self(self.0 * f64::from(rhs))
}
}
impl std::ops::Mul<f32> for Distance {
type Output = Self;
fn mul(self, rhs: f32) -> Self::Output {
Self(self.0 * f64::from(rhs))
}
}
impl std::ops::Mul<f64> for Distance {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl std::ops::Div<u8> for Distance {
type Output = Self;
fn div(self, rhs: u8) -> Self::Output {
Self(self.0 / f64::from(rhs))
}
}
impl std::ops::Div<u16> for Distance {
type Output = Self;
fn div(self, rhs: u16) -> Self::Output {
Self(self.0 / f64::from(rhs))
}
}
impl std::ops::Div<u32> for Distance {
type Output = Self;
fn div(self, rhs: u32) -> Self::Output {
Self(self.0 / f64::from(rhs))
}
}
impl std::ops::Div<f32> for Distance {
type Output = Self;
fn div(self, rhs: f32) -> Self::Output {
Self(self.0 / f64::from(rhs))
}
}
impl std::ops::Div<f64> for Distance {
type Output = Self;
fn div(self, rhs: f64) -> Self::Output {
Self(self.0 / rhs)
}
}
impl std::ops::Div for Distance {
type Output = f64;
fn div(self, rhs: Self) -> Self::Output {
self.0 / rhs.0
}
}
impl std::ops::Rem<u8> for Distance {
type Output = Self;
fn rem(self, rhs: u8) -> Self::Output {
Self(self.0 % f64::from(rhs))
}
}
impl std::ops::Rem<u16> for Distance {
type Output = Self;
fn rem(self, rhs: u16) -> Self::Output {
Self(self.0 % f64::from(rhs))
}
}
impl std::ops::Rem<u32> for Distance {
type Output = Self;
fn rem(self, rhs: u32) -> Self::Output {
Self(self.0 % f64::from(rhs))
}
}
impl std::ops::Rem<f32> for Distance {
type Output = Self;
fn rem(self, rhs: f32) -> Self::Output {
Self(self.0 % f64::from(rhs))
}
}
impl std::ops::Rem<f64> for Distance {
type Output = Self;
fn rem(self, rhs: f64) -> Self::Output {
Self(self.0 % rhs)
}
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn distance_parser<'src>()
-> impl Parser<'src, &'src str, Distance, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
crate::utils::unsigned_f64_parser()
.then_ignore(whitespace().or_not())
.then_ignore(just('m'))
.map(Distance)
}
#[derive(
Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct GridCoordinates {
x: u16,
y: u16,
}
impl GridCoordinates {
#[must_use]
pub const fn new(x: u16, y: u16) -> Self {
Self { x, y }
}
#[must_use]
pub const fn x(&self) -> u16 {
self.x
}
#[must_use]
pub const fn y(&self) -> u16 {
self.y
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridCoordinateOffset {
x: i32,
y: i32,
}
impl GridCoordinateOffset {
#[must_use]
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
#[must_use]
pub const fn x(&self) -> i32 {
self.x
}
#[must_use]
pub const fn y(&self) -> i32 {
self.y
}
}
impl std::ops::Add<GridCoordinateOffset> for GridCoordinates {
type Output = Self;
fn add(self, rhs: GridCoordinateOffset) -> Self::Output {
Self::new(
(<u16 as Into<i32>>::into(self.x).saturating_add(rhs.x))
.try_into()
.unwrap_or(if rhs.x > 0 { u16::MAX } else { u16::MIN }),
(<u16 as Into<i32>>::into(self.y).saturating_add(rhs.y))
.try_into()
.unwrap_or(if rhs.y > 0 { u16::MAX } else { u16::MIN }),
)
}
}
impl std::ops::Sub<Self> for GridCoordinates {
type Output = GridCoordinateOffset;
fn sub(self, rhs: Self) -> Self::Output {
GridCoordinateOffset::new(
<u16 as Into<i32>>::into(self.x).saturating_sub(<u16 as Into<i32>>::into(rhs.x)),
<u16 as Into<i32>>::into(self.y).saturating_sub(<u16 as Into<i32>>::into(rhs.y)),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridRectangle {
lower_left_corner: GridCoordinates,
upper_right_corner: GridCoordinates,
}
impl GridRectangle {
#[must_use]
pub fn new(corner1: GridCoordinates, corner2: GridCoordinates) -> Self {
Self {
lower_left_corner: GridCoordinates::new(
corner1.x().min(corner2.x()),
corner1.y().min(corner2.y()),
),
upper_right_corner: GridCoordinates::new(
corner1.x().max(corner2.x()),
corner1.y().max(corner2.y()),
),
}
}
}
pub trait GridRectangleLike {
#[must_use]
fn grid_rectangle(&self) -> GridRectangle;
#[must_use]
fn lower_left_corner(&self) -> GridCoordinates {
self.grid_rectangle().lower_left_corner().to_owned()
}
#[must_use]
fn lower_right_corner(&self) -> GridCoordinates {
GridCoordinates::new(
self.grid_rectangle().upper_right_corner().x(),
self.grid_rectangle().lower_left_corner().y(),
)
}
#[must_use]
fn upper_left_corner(&self) -> GridCoordinates {
GridCoordinates::new(
self.grid_rectangle().lower_left_corner().x(),
self.grid_rectangle().upper_right_corner().y(),
)
}
#[must_use]
fn upper_right_corner(&self) -> GridCoordinates {
self.grid_rectangle().upper_right_corner().to_owned()
}
#[must_use]
fn size_x(&self) -> u16 {
self.grid_rectangle().size_x()
}
#[must_use]
fn size_y(&self) -> u16 {
self.grid_rectangle().size_y()
}
#[must_use]
fn x_range(&self) -> std::ops::RangeInclusive<u16> {
self.lower_left_corner().x()..=self.upper_right_corner().x()
}
#[must_use]
fn y_range(&self) -> std::ops::RangeInclusive<u16> {
self.lower_left_corner().y()..=self.upper_right_corner().y()
}
#[must_use]
fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
self.lower_left_corner().x() <= grid_coordinates.x()
&& grid_coordinates.x() <= self.upper_right_corner().x()
&& self.lower_left_corner().y() <= grid_coordinates.y()
&& grid_coordinates.y() <= self.upper_right_corner().y()
}
#[must_use]
fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
where
O: GridRectangleLike,
{
let self_x_range: ranges::GenericRange<u16> = self.x_range().into();
let self_y_range: ranges::GenericRange<u16> = self.y_range().into();
let other_x_range: ranges::GenericRange<u16> = other.x_range().into();
let other_y_range: ranges::GenericRange<u16> = other.y_range().into();
let x_intersection = self_x_range.intersect(other_x_range);
let y_intersection = self_y_range.intersect(other_y_range);
match (x_intersection, y_intersection) {
(
ranges::OperationResult::Single(x_range),
ranges::OperationResult::Single(y_range),
) => {
use std::ops::Bound;
use std::ops::RangeBounds as _;
match (
x_range.start_bound(),
x_range.end_bound(),
y_range.start_bound(),
y_range.end_bound(),
) {
(
Bound::Included(start_x),
Bound::Included(end_x),
Bound::Included(start_y),
Bound::Included(end_y),
) => Some(GridRectangle::new(
GridCoordinates::new(*start_x, *start_y),
GridCoordinates::new(*end_x, *end_y),
)),
_ => None,
}
}
_ => None,
}
}
#[must_use]
fn pps_hud_config(&self) -> String {
let lower_left_corner_x = 256f32 * f32::from(self.lower_left_corner().x());
let lower_left_corner_y = 256f32 * f32::from(self.lower_left_corner().y());
format!(
"<{lower_left_corner_x},{lower_left_corner_y},0>/{}/{}/1",
f32::from(self.size_x()),
f32::from(self.size_y())
)
}
}
impl GridRectangleLike for GridRectangle {
fn grid_rectangle(&self) -> GridRectangle {
self.to_owned()
}
fn lower_left_corner(&self) -> GridCoordinates {
self.lower_left_corner.to_owned()
}
fn upper_right_corner(&self) -> GridCoordinates {
self.upper_right_corner.to_owned()
}
fn size_x(&self) -> u16 {
self.upper_right_corner
.x()
.saturating_sub(self.lower_left_corner().x())
.saturating_add(1)
}
fn size_y(&self) -> u16 {
self.upper_right_corner
.y()
.saturating_sub(self.lower_left_corner().y())
.saturating_add(1)
}
fn x_range(&self) -> std::ops::RangeInclusive<u16> {
self.lower_left_corner.x()..=self.upper_right_corner.x()
}
fn y_range(&self) -> std::ops::RangeInclusive<u16> {
self.lower_left_corner.y()..=self.upper_right_corner.y()
}
}
impl GridRectangleLike for MapTileDescriptor {
fn grid_rectangle(&self) -> GridRectangle {
GridRectangle::new(
self.lower_left_corner,
GridCoordinates::new(
self.lower_left_corner
.x()
.saturating_add(self.zoom_level.tile_size())
.saturating_sub(1),
self.lower_left_corner
.y()
.saturating_add(self.zoom_level.tile_size())
.saturating_sub(1),
),
)
}
}
pub trait GridCoordinatesExt {
fn bounding_rectangle(&self) -> Option<GridRectangle>;
}
impl GridCoordinatesExt for Vec<GridCoordinates> {
fn bounding_rectangle(&self) -> Option<GridRectangle> {
if self.is_empty() {
return None;
}
let (xs, ys): (Vec<u16>, Vec<u16>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
#[expect(
clippy::unwrap_used,
reason = "we checked above that the container is non-empty"
)]
let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
#[expect(
clippy::unwrap_used,
reason = "we checked above that the container is non-empty"
)]
let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
Some(GridRectangle {
lower_left_corner: GridCoordinates::new(*min_x, *min_y),
upper_right_corner: GridCoordinates::new(*max_x, *max_y),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
pub struct RegionCoordinates {
x: f32,
y: f32,
z: f32,
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn region_coordinates_parser<'src>()
-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
{
just('{')
.ignore_then(whitespace().or_not())
.ignore_then(f32_parser())
.then_ignore(just(','))
.then_ignore(whitespace().or_not())
.then(f32_parser())
.then_ignore(just(','))
.then_ignore(whitespace().or_not())
.then(f32_parser())
.then_ignore(whitespace().or_not())
.then_ignore(just('}'))
.map(|((x, y), z)| RegionCoordinates::new(x, y, z))
}
impl RegionCoordinates {
#[must_use]
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
#[must_use]
pub const fn x(&self) -> f32 {
self.x
}
#[must_use]
pub const fn y(&self) -> f32 {
self.y
}
#[must_use]
pub const fn z(&self) -> f32 {
self.z
}
#[must_use]
pub fn in_bounds(&self) -> bool {
self.x >= 0f32
&& self.x < 256f32
&& self.y >= 0f32
&& self.y < 256f32
&& self.z >= 0f32
&& self.z < 4096f32
}
}
impl From<crate::lsl::Vector> for RegionCoordinates {
fn from(value: crate::lsl::Vector) -> Self {
Self {
x: value.x,
y: value.y,
z: value.z,
}
}
}
#[nutype::nutype(
sanitize(trim),
validate(len_char_min = 2, len_char_max = 35),
derive(
Debug,
Clone,
Display,
Hash,
PartialEq,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
AsRef
)
)]
pub struct RegionName(String);
#[cfg(feature = "chumsky")]
#[must_use]
pub fn url_region_name_parser<'src>()
-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
url_text_component_parser().try_map(|region_name, span| {
RegionName::try_new(region_name).map_err(|err| chumsky::error::Rich::custom(span, err))
})
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn region_name_parser<'src>()
-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
any()
.filter(|c: &char| {
c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
})
.repeated()
.at_least(2)
.collect::<String>()
.try_map(|region_name, span| {
RegionName::try_new(region_name).map_err(|err| chumsky::error::Rich::custom(span, err))
})
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Location {
pub region_name: RegionName,
pub x: u8,
pub y: u8,
pub z: u16,
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn location_parser<'src>()
-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
region_name_parser()
.then_ignore(just('/'))
.then(u8_parser())
.then_ignore(just('/'))
.then(u8_parser())
.then_ignore(just('/'))
.then(u16_parser())
.map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn url_location_parser<'src>()
-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
url_region_name_parser()
.then_ignore(just('/'))
.then(u8_parser())
.then_ignore(just('/'))
.then(u8_parser())
.then_ignore(just('/'))
.then(u16_parser())
.map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn url_encoded_location_parser<'src>()
-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
url_text_component_parser().try_map(|s, span| {
location_parser().parse(&s).into_result().map_err(|err| {
chumsky::error::Rich::custom(
span,
format!("Parsing {s} as location failed with: {err:#?}"),
)
})
})
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
pub enum LocationParseError {
#[error(
"unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
)]
UnexpectedComponentCount(String, usize),
#[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
UnexpectedScheme(String, String),
#[error(
"unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
)]
UnexpectedNonEmptySecondComponent(String, String),
#[error(
"unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
)]
UnexpectedHost(String, String),
#[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
UnexpectedPath(String, String),
#[error("error parsing the region name {0}: {1}")]
RegionName(String, RegionNameError),
#[error("error parsing the X coordinate {0}: {1}")]
X(String, std::num::ParseIntError),
#[error("error parsing the Y coordinate {0}: {1}")]
Y(String, std::num::ParseIntError),
#[error("error parsing the Z coordinate {0}: {1}")]
Z(String, std::num::ParseIntError),
}
impl std::str::FromStr for Location {
type Err = LocationParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let usb_location = s
.split_once(',')
.map_or(s, |(usb_location, _usb_comment)| usb_location);
let parts = usb_location.split('/').collect::<Vec<_>>();
if let [region_name, x, y, z] = parts.as_slice() {
let region_name = RegionName::try_new(region_name.replace("%20", " "))
.map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
let x = x
.parse()
.map_err(|err| LocationParseError::X(s.to_owned(), err))?;
let y = y
.parse()
.map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
let z = z
.parse()
.map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
return Ok(Self {
region_name,
x,
y,
z,
});
}
if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
if *scheme != "http:" && *scheme != "https:" {
return Err(LocationParseError::UnexpectedScheme(
s.to_owned(),
scheme.to_string(),
));
}
if !second_component.is_empty() {
return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
s.to_owned(),
second_component.to_string(),
));
}
if *host != "maps.secondlife.com" && *host != "slurl.com" {
return Err(LocationParseError::UnexpectedHost(
s.to_owned(),
host.to_string(),
));
}
if *path != "secondlife" {
return Err(LocationParseError::UnexpectedPath(
s.to_owned(),
path.to_string(),
));
}
let region_name = RegionName::try_new(region_name.replace("%20", " "))
.map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
let x = x
.parse()
.map_err(|err| LocationParseError::X(s.to_owned(), err))?;
let y = y
.parse()
.map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
let z = z
.parse()
.map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
return Ok(Self {
region_name,
x,
y,
z,
});
}
Err(LocationParseError::UnexpectedComponentCount(
s.to_owned(),
parts.len(),
))
}
}
impl Location {
#[must_use]
pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
Self {
region_name,
x,
y,
z,
}
}
#[must_use]
pub const fn region_name(&self) -> &RegionName {
&self.region_name
}
#[must_use]
pub const fn x(&self) -> u8 {
self.x
}
#[must_use]
pub const fn y(&self) -> u8 {
self.y
}
#[must_use]
pub const fn z(&self) -> u16 {
self.z
}
#[must_use]
pub fn as_maps_url(&self) -> String {
format!(
"https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
self.region_name, self.x, self.y, self.z
)
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UnconstrainedLocation {
pub region_name: RegionName,
pub x: i16,
pub y: i16,
pub z: i32,
}
impl UnconstrainedLocation {
#[must_use]
pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
Self {
region_name,
x,
y,
z,
}
}
#[must_use]
pub const fn region_name(&self) -> &RegionName {
&self.region_name
}
#[must_use]
pub const fn x(&self) -> i16 {
self.x
}
#[must_use]
pub const fn y(&self) -> i16 {
self.y
}
#[must_use]
pub const fn z(&self) -> i32 {
self.z
}
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn unconstrained_location_parser<'src>() -> impl Parser<
'src,
&'src str,
UnconstrainedLocation,
chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
> {
region_name_parser()
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i32_parser())
.map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
'src,
&'src str,
UnconstrainedLocation,
chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
> {
url_region_name_parser()
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i32_parser())
.map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
}
#[cfg(feature = "chumsky")]
#[must_use]
pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
'src,
&'src str,
UnconstrainedLocation,
chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
> {
url_region_name_parser()
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i16_parser())
.then_ignore(just('/'))
.then(i32_parser())
.map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
}
impl TryFrom<UnconstrainedLocation> for Location {
type Error = std::num::TryFromIntError;
fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
Ok(Self::new(
value.region_name,
value.x.try_into()?,
value.y.try_into()?,
value.z.try_into()?,
))
}
}
impl From<Location> for UnconstrainedLocation {
fn from(value: Location) -> Self {
Self {
region_name: value.region_name,
x: value.x.into(),
y: value.y.into(),
z: value.z.into(),
}
}
}
#[nutype::nutype(
validate(greater_or_equal = 1, less_or_equal = 8),
derive(
Debug,
Clone,
Copy,
Display,
FromStr,
Hash,
PartialEq,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize
)
)]
pub struct ZoomLevel(u8);
#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
pub enum ZoomFitError {
#[error("region size in x direction can not be zero")]
RegionSizeXZero,
#[error("region size in y direction can not be zero")]
RegionSizeYZero,
#[error("output image size in x direction can not be zero")]
OutputSizeXZero,
#[error("output image size in y direction can not be zero")]
OutputSizeYZero,
#[error("error converting a logarithm value into a u8")]
LogarithmConversionError(#[from] std::num::TryFromIntError),
#[error("error creating zoom level from calculated value")]
ZoomLevelError(#[from] ZoomLevelError),
}
impl ZoomLevel {
#[must_use]
pub fn tile_size(&self) -> u16 {
let exponent: u32 = self.into_inner().into();
let exponent = exponent.saturating_sub(1);
2u16.pow(exponent)
}
#[expect(
clippy::arithmetic_side_effects,
reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
)]
#[must_use]
pub fn tile_size_in_pixels(&self) -> u32 {
let tile_size: u32 = self.tile_size().into();
let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
tile_size * region_size_in_map_tile_in_pixels
}
#[must_use]
pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
let tile_size = self.tile_size();
#[expect(
clippy::arithmetic_side_effects,
reason = "remainder should not have any side-effects since tile_size is never 0 (no division by zero issues) or negative (no issues with x or y being e.g. i16::MIN which overflows when the sign is flipped)"
)]
GridCoordinates {
x: x.saturating_sub(x % tile_size),
y: y.saturating_sub(y % tile_size),
}
}
#[must_use]
pub fn pixels_per_region(&self) -> u16 {
let exponent: u32 = self.into_inner().into();
let exponent = exponent.saturating_sub(1);
let exponent = 8u32.saturating_sub(exponent);
2u16.pow(exponent)
}
#[must_use]
pub fn pixels_per_meter(&self) -> f32 {
f32::from(self.pixels_per_region()) / 256f32
}
pub fn max_zoom_level_to_fit_regions_into_output_image(
region_x: u16,
region_y: u16,
output_x: u32,
output_y: u32,
) -> Result<Self, ZoomFitError> {
if region_x == 0 {
return Err(ZoomFitError::RegionSizeXZero);
}
if region_y == 0 {
return Err(ZoomFitError::RegionSizeYZero);
}
if output_x == 0 {
return Err(ZoomFitError::OutputSizeXZero);
}
if output_y == 0 {
return Err(ZoomFitError::OutputSizeYZero);
}
let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x.into());
let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y.into());
let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
8,
output_pixels_per_region_x
.ilog2()
.try_into()
.map_err(ZoomFitError::LogarithmConversionError)?,
));
let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
8,
output_pixels_per_region_y
.ilog2()
.try_into()
.map_err(ZoomFitError::LogarithmConversionError)?,
));
Ok(Self::try_new(std::cmp::max(
max_zoom_level_x,
max_zoom_level_y,
))?)
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[expect(
clippy::module_name_repetitions,
reason = "the type is used outside this module"
)]
pub struct MapTileDescriptor {
zoom_level: ZoomLevel,
lower_left_corner: GridCoordinates,
}
impl MapTileDescriptor {
#[must_use]
pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
Self {
zoom_level,
lower_left_corner,
}
}
#[must_use]
pub const fn zoom_level(&self) -> &ZoomLevel {
&self.zoom_level
}
#[must_use]
pub const fn lower_left_corner(&self) -> &GridCoordinates {
&self.lower_left_corner
}
#[must_use]
pub fn tile_size(&self) -> u16 {
self.zoom_level.tile_size()
}
#[must_use]
pub fn tile_size_in_pixels(&self) -> u32 {
self.zoom_level.tile_size_in_pixels()
}
#[must_use]
pub fn grid_rectangle(&self) -> GridRectangle {
GridRectangle::new(
self.lower_left_corner,
GridCoordinates::new(
self.lower_left_corner
.x()
.saturating_add(self.zoom_level.tile_size())
.saturating_sub(1),
self.lower_left_corner
.y()
.saturating_add(self.zoom_level.tile_size())
.saturating_sub(1),
),
)
}
}
#[derive(Debug, Clone)]
pub struct USBWaypoint {
location: Location,
comment: Option<String>,
}
impl USBWaypoint {
#[must_use]
pub const fn new(location: Location, comment: Option<String>) -> Self {
Self { location, comment }
}
#[must_use]
pub const fn location(&self) -> &Location {
&self.location
}
#[must_use]
pub fn region_coordinates(&self) -> RegionCoordinates {
RegionCoordinates::new(
f32::from(self.location.x()),
f32::from(self.location.y()),
f32::from(self.location.z()),
)
}
#[must_use]
pub const fn comment(&self) -> Option<&String> {
self.comment.as_ref()
}
}
impl std::fmt::Display for USBWaypoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.location.as_maps_url())?;
if let Some(comment) = &self.comment {
write!(f, ",{comment}")?;
}
Ok(())
}
}
impl std::str::FromStr for USBWaypoint {
type Err = LocationParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((location, comment)) = s.split_once(',') {
Ok(Self {
location: location.parse()?,
comment: Some(comment.to_owned()),
})
} else {
Ok(Self {
location: s.parse()?,
comment: None,
})
}
}
}
#[derive(Debug, Clone)]
pub struct USBNotecard {
waypoints: Vec<USBWaypoint>,
}
#[derive(Debug, thiserror::Error, strum::EnumIs)]
pub enum USBNotecardLoadError {
#[error("I/O error opening or reading the file: {0}")]
Io(#[from] std::io::Error),
#[error("parse error deserializing the USB notecard lines: {0}")]
LocationParseError(#[from] LocationParseError),
}
impl USBNotecard {
#[must_use]
pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
Self { waypoints }
}
#[must_use]
pub fn waypoints(&self) -> &[USBWaypoint] {
&self.waypoints
}
pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
let contents = std::fs::read_to_string(filename)?;
Ok(contents.parse()?)
}
}
impl std::fmt::Display for USBNotecard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for waypoint in &self.waypoints {
writeln!(f, "{waypoint}")?;
}
Ok(())
}
}
impl std::str::FromStr for USBNotecard {
type Err = LocationParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.lines()
.map(|line| line.parse::<USBWaypoint>())
.collect::<Result<Vec<_>, _>>()
.map(|waypoints| Self { waypoints })
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
"Beach%20Valley/110/67/24".parse::<Location>(),
Ok(Location {
region_name: RegionName::try_new("Beach Valley")?,
x: 110,
y: 67,
z: 24
}),
);
Ok(())
}
#[test]
fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
"http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
Ok(Location {
region_name: RegionName::try_new("Beach Valley")?,
x: 110,
y: 67,
z: 24
}),
);
Ok(())
}
#[test]
fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
"http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
Ok(Location {
region_name: RegionName::try_new("Beach Valley")?,
x: 110,
y: 67,
z: 24
}),
);
Ok(())
}
#[test]
fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
"Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
Ok(Location {
region_name: RegionName::try_new("Beach Valley")?,
x: 110,
y: 67,
z: 24
}),
);
Ok(())
}
#[test]
fn test_grid_rectangle_intersection_upper_right_corner()
-> Result<(), Box<dyn std::error::Error>> {
let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
assert_eq!(
rect1.intersect(&rect2),
Some(GridRectangle::new(
GridCoordinates::new(15, 15),
GridCoordinates::new(20, 20),
))
);
Ok(())
}
#[test]
fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
{
let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
assert_eq!(
rect1.intersect(&rect2),
Some(GridRectangle::new(
GridCoordinates::new(10, 15),
GridCoordinates::new(15, 20),
))
);
Ok(())
}
#[test]
fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
{
let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
assert_eq!(
rect1.intersect(&rect2),
Some(GridRectangle::new(
GridCoordinates::new(10, 10),
GridCoordinates::new(15, 15),
))
);
Ok(())
}
#[test]
fn test_grid_rectangle_intersection_lower_right_corner()
-> Result<(), Box<dyn std::error::Error>> {
let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
assert_eq!(
rect1.intersect(&rect2),
Some(GridRectangle::new(
GridCoordinates::new(15, 10),
GridCoordinates::new(20, 15),
))
);
Ok(())
}
#[test]
fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
assert_eq!(rect1.intersect(&rect2), None);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
let region_name = "Viterbo";
assert_eq!(
url_region_name_parser().parse(region_name).into_result(),
Ok(RegionName::try_new(region_name)?)
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
let region_name = "Da Boom";
let input = region_name.replace(' ', "%20");
assert_eq!(
url_region_name_parser().parse(&input).into_result(),
Ok(RegionName::try_new(region_name)?)
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
let region_name = "Da Boom";
assert_eq!(
region_name_parser().parse(region_name).into_result(),
Ok(RegionName::try_new(region_name)?)
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
let region_name = "Viterbo";
let input = format!("{region_name}/1/2/300");
assert_eq!(
url_location_parser().parse(&input).into_result(),
Ok(Location {
region_name: RegionName::try_new(region_name)?,
x: 1,
y: 2,
z: 300
})
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
let region_name = "Da Boom";
let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
assert_eq!(
url_location_parser().parse(&input).into_result(),
Ok(Location {
region_name: RegionName::try_new(region_name)?,
x: 1,
y: 2,
z: 300
})
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_url_location_parser_url_whitespace_single_digit_after_space()
-> Result<(), Box<dyn std::error::Error>> {
let region_name = "Foo Bar 3";
let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
assert_eq!(
url_location_parser().parse(&input).into_result(),
Ok(Location {
region_name: RegionName::try_new(region_name)?,
x: 1,
y: 2,
z: 300
})
);
Ok(())
}
#[cfg(feature = "chumsky")]
#[test]
fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
region_coordinates_parser()
.parse("{ 63.0486, 45.2515, 1501.08 }")
.into_result(),
Ok(RegionCoordinates {
x: 63.0486,
y: 45.2515,
z: 1501.08,
})
);
Ok(())
}
}