1#[cfg(feature = "chumsky")]
4use chumsky::{
5 IterParser as _, Parser,
6 prelude::{any, just},
7 text::whitespace,
8};
9
10#[cfg(feature = "chumsky")]
11use crate::utils::{
12 f32_parser, i16_parser, i32_parser, u8_parser, u16_parser, url_text_component_parser,
13};
14
15#[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
17pub struct Distance(f64);
18
19impl std::fmt::Display for Distance {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 write!(f, "{} m", self.0)
22 }
23}
24
25impl std::ops::Add for Distance {
26 type Output = Self;
27
28 fn add(self, rhs: Self) -> Self::Output {
29 Self(self.0 + rhs.0)
30 }
31}
32
33impl std::ops::Sub for Distance {
34 type Output = Self;
35
36 fn sub(self, rhs: Self) -> Self::Output {
37 Self(self.0 - rhs.0)
38 }
39}
40
41impl std::ops::Mul<u8> for Distance {
42 type Output = Self;
43
44 fn mul(self, rhs: u8) -> Self::Output {
45 Self(self.0 * f64::from(rhs))
46 }
47}
48
49impl std::ops::Mul<u16> for Distance {
50 type Output = Self;
51
52 fn mul(self, rhs: u16) -> Self::Output {
53 Self(self.0 * f64::from(rhs))
54 }
55}
56
57impl std::ops::Mul<u32> for Distance {
58 type Output = Self;
59
60 fn mul(self, rhs: u32) -> Self::Output {
61 Self(self.0 * f64::from(rhs))
62 }
63}
64
65impl std::ops::Mul<f32> for Distance {
66 type Output = Self;
67
68 fn mul(self, rhs: f32) -> Self::Output {
69 Self(self.0 * f64::from(rhs))
70 }
71}
72
73impl std::ops::Mul<f64> for Distance {
74 type Output = Self;
75
76 fn mul(self, rhs: f64) -> Self::Output {
77 Self(self.0 * rhs)
78 }
79}
80
81impl std::ops::Div<u8> for Distance {
82 type Output = Self;
83
84 fn div(self, rhs: u8) -> Self::Output {
85 Self(self.0 / f64::from(rhs))
86 }
87}
88
89impl std::ops::Div<u16> for Distance {
90 type Output = Self;
91
92 fn div(self, rhs: u16) -> Self::Output {
93 Self(self.0 / f64::from(rhs))
94 }
95}
96
97impl std::ops::Div<u32> for Distance {
98 type Output = Self;
99
100 fn div(self, rhs: u32) -> Self::Output {
101 Self(self.0 / f64::from(rhs))
102 }
103}
104
105impl std::ops::Div<f32> for Distance {
106 type Output = Self;
107
108 fn div(self, rhs: f32) -> Self::Output {
109 Self(self.0 / f64::from(rhs))
110 }
111}
112
113impl std::ops::Div<f64> for Distance {
114 type Output = Self;
115
116 fn div(self, rhs: f64) -> Self::Output {
117 Self(self.0 / rhs)
118 }
119}
120
121impl std::ops::Div for Distance {
122 type Output = f64;
123
124 fn div(self, rhs: Self) -> Self::Output {
125 self.0 / rhs.0
126 }
127}
128
129impl std::ops::Rem<u8> for Distance {
130 type Output = Self;
131
132 fn rem(self, rhs: u8) -> Self::Output {
133 Self(self.0 % f64::from(rhs))
134 }
135}
136
137impl std::ops::Rem<u16> for Distance {
138 type Output = Self;
139
140 fn rem(self, rhs: u16) -> Self::Output {
141 Self(self.0 % f64::from(rhs))
142 }
143}
144
145impl std::ops::Rem<u32> for Distance {
146 type Output = Self;
147
148 fn rem(self, rhs: u32) -> Self::Output {
149 Self(self.0 % f64::from(rhs))
150 }
151}
152
153impl std::ops::Rem<f32> for Distance {
154 type Output = Self;
155
156 fn rem(self, rhs: f32) -> Self::Output {
157 Self(self.0 % f64::from(rhs))
158 }
159}
160
161impl std::ops::Rem<f64> for Distance {
162 type Output = Self;
163
164 fn rem(self, rhs: f64) -> Self::Output {
165 Self(self.0 % rhs)
166 }
167}
168
169#[cfg(feature = "chumsky")]
177#[must_use]
178pub fn distance_parser<'src>()
179-> impl Parser<'src, &'src str, Distance, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
180 crate::utils::unsigned_f64_parser()
181 .then_ignore(whitespace().or_not())
182 .then_ignore(just('m'))
183 .map(Distance)
184}
185
186#[derive(
190 Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
191)]
192pub struct GridCoordinates {
193 x: u16,
198 y: u16,
203}
204
205impl GridCoordinates {
206 #[must_use]
208 pub const fn new(x: u16, y: u16) -> Self {
209 Self { x, y }
210 }
211
212 #[must_use]
214 pub const fn x(&self) -> u16 {
215 self.x
216 }
217
218 #[must_use]
220 pub const fn y(&self) -> u16 {
221 self.y
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct GridCoordinateOffset {
228 x: i32,
230 y: i32,
232}
233
234impl GridCoordinateOffset {
235 #[must_use]
237 pub const fn new(x: i32, y: i32) -> Self {
238 Self { x, y }
239 }
240
241 #[must_use]
243 pub const fn x(&self) -> i32 {
244 self.x
245 }
246
247 #[must_use]
249 pub const fn y(&self) -> i32 {
250 self.y
251 }
252}
253
254impl std::ops::Add<GridCoordinateOffset> for GridCoordinates {
255 type Output = Self;
256
257 fn add(self, rhs: GridCoordinateOffset) -> Self::Output {
258 Self::new(
259 (<u16 as Into<i32>>::into(self.x).saturating_add(rhs.x))
260 .try_into()
261 .unwrap_or(if rhs.x > 0 { u16::MAX } else { u16::MIN }),
262 (<u16 as Into<i32>>::into(self.y).saturating_add(rhs.y))
263 .try_into()
264 .unwrap_or(if rhs.y > 0 { u16::MAX } else { u16::MIN }),
265 )
266 }
267}
268
269impl std::ops::Sub<Self> for GridCoordinates {
270 type Output = GridCoordinateOffset;
271
272 fn sub(self, rhs: Self) -> Self::Output {
273 GridCoordinateOffset::new(
274 <u16 as Into<i32>>::into(self.x).saturating_sub(<u16 as Into<i32>>::into(rhs.x)),
275 <u16 as Into<i32>>::into(self.y).saturating_sub(<u16 as Into<i32>>::into(rhs.y)),
276 )
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct GridRectangle {
284 lower_left_corner: GridCoordinates,
286 upper_right_corner: GridCoordinates,
288}
289
290impl GridRectangle {
291 #[must_use]
293 pub fn new(corner1: GridCoordinates, corner2: GridCoordinates) -> Self {
294 Self {
295 lower_left_corner: GridCoordinates::new(
296 corner1.x().min(corner2.x()),
297 corner1.y().min(corner2.y()),
298 ),
299 upper_right_corner: GridCoordinates::new(
300 corner1.x().max(corner2.x()),
301 corner1.y().max(corner2.y()),
302 ),
303 }
304 }
305}
306
307pub trait GridRectangleLike {
310 #[must_use]
312 fn grid_rectangle(&self) -> GridRectangle;
313
314 #[must_use]
316 fn lower_left_corner(&self) -> GridCoordinates {
317 self.grid_rectangle().lower_left_corner().to_owned()
318 }
319
320 #[must_use]
322 fn lower_right_corner(&self) -> GridCoordinates {
323 GridCoordinates::new(
324 self.grid_rectangle().upper_right_corner().x(),
325 self.grid_rectangle().lower_left_corner().y(),
326 )
327 }
328
329 #[must_use]
331 fn upper_left_corner(&self) -> GridCoordinates {
332 GridCoordinates::new(
333 self.grid_rectangle().lower_left_corner().x(),
334 self.grid_rectangle().upper_right_corner().y(),
335 )
336 }
337
338 #[must_use]
340 fn upper_right_corner(&self) -> GridCoordinates {
341 self.grid_rectangle().upper_right_corner().to_owned()
342 }
343
344 #[must_use]
346 fn size_x(&self) -> u16 {
347 self.grid_rectangle().size_x()
348 }
349
350 #[must_use]
352 fn size_y(&self) -> u16 {
353 self.grid_rectangle().size_y()
354 }
355
356 #[must_use]
358 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
359 self.lower_left_corner().x()..=self.upper_right_corner().x()
360 }
361
362 #[must_use]
364 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
365 self.lower_left_corner().y()..=self.upper_right_corner().y()
366 }
367
368 #[must_use]
370 fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
371 self.lower_left_corner().x() <= grid_coordinates.x()
372 && grid_coordinates.x() <= self.upper_right_corner().x()
373 && self.lower_left_corner().y() <= grid_coordinates.y()
374 && grid_coordinates.y() <= self.upper_right_corner().y()
375 }
376
377 #[must_use]
380 fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
381 where
382 O: GridRectangleLike,
383 {
384 let self_x_range: ranges::GenericRange<u16> = self.x_range().into();
385 let self_y_range: ranges::GenericRange<u16> = self.y_range().into();
386 let other_x_range: ranges::GenericRange<u16> = other.x_range().into();
387 let other_y_range: ranges::GenericRange<u16> = other.y_range().into();
388 let x_intersection = self_x_range.intersect(other_x_range);
389 let y_intersection = self_y_range.intersect(other_y_range);
390 match (x_intersection, y_intersection) {
391 (
392 ranges::OperationResult::Single(x_range),
393 ranges::OperationResult::Single(y_range),
394 ) => {
395 use std::ops::Bound;
396 use std::ops::RangeBounds as _;
397 match (
398 x_range.start_bound(),
399 x_range.end_bound(),
400 y_range.start_bound(),
401 y_range.end_bound(),
402 ) {
403 (
404 Bound::Included(start_x),
405 Bound::Included(end_x),
406 Bound::Included(start_y),
407 Bound::Included(end_y),
408 ) => Some(GridRectangle::new(
409 GridCoordinates::new(*start_x, *start_y),
410 GridCoordinates::new(*end_x, *end_y),
411 )),
412 _ => None,
413 }
414 }
415 _ => None,
416 }
417 }
418
419 #[must_use]
430 fn pps_hud_config(&self) -> String {
431 let lower_left_corner_x = 256f32 * f32::from(self.lower_left_corner().x());
432 let lower_left_corner_y = 256f32 * f32::from(self.lower_left_corner().y());
433 format!(
438 "<{lower_left_corner_x},{lower_left_corner_y},0>/{}/{}/1",
439 f32::from(self.size_x()),
440 f32::from(self.size_y())
441 )
442 }
443}
444
445impl GridRectangleLike for GridRectangle {
446 fn grid_rectangle(&self) -> GridRectangle {
447 self.to_owned()
448 }
449
450 fn lower_left_corner(&self) -> GridCoordinates {
451 self.lower_left_corner.to_owned()
452 }
453
454 fn upper_right_corner(&self) -> GridCoordinates {
455 self.upper_right_corner.to_owned()
456 }
457
458 fn size_x(&self) -> u16 {
459 self.upper_right_corner
460 .x()
461 .saturating_sub(self.lower_left_corner().x())
462 .saturating_add(1)
463 }
464
465 fn size_y(&self) -> u16 {
466 self.upper_right_corner
467 .y()
468 .saturating_sub(self.lower_left_corner().y())
469 .saturating_add(1)
470 }
471
472 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
473 self.lower_left_corner.x()..=self.upper_right_corner.x()
474 }
475
476 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
477 self.lower_left_corner.y()..=self.upper_right_corner.y()
478 }
479}
480
481impl GridRectangleLike for MapTileDescriptor {
482 fn grid_rectangle(&self) -> GridRectangle {
483 GridRectangle::new(
484 self.lower_left_corner,
485 GridCoordinates::new(
486 self.lower_left_corner
487 .x()
488 .saturating_add(self.zoom_level.tile_size())
489 .saturating_sub(1),
490 self.lower_left_corner
491 .y()
492 .saturating_add(self.zoom_level.tile_size())
493 .saturating_sub(1),
494 ),
495 )
496 }
497}
498
499pub trait GridCoordinatesExt {
501 fn bounding_rectangle(&self) -> Option<GridRectangle>;
507}
508
509impl GridCoordinatesExt for Vec<GridCoordinates> {
510 fn bounding_rectangle(&self) -> Option<GridRectangle> {
511 if self.is_empty() {
512 return None;
513 }
514 let (xs, ys): (Vec<u16>, Vec<u16>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
515 #[expect(
517 clippy::unwrap_used,
518 reason = "we checked above that the container is non-empty"
519 )]
520 let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
521 #[expect(
522 clippy::unwrap_used,
523 reason = "we checked above that the container is non-empty"
524 )]
525 let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
526 Some(GridRectangle {
527 lower_left_corner: GridCoordinates::new(*min_x, *min_y),
528 upper_right_corner: GridCoordinates::new(*max_x, *max_y),
529 })
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
541pub struct RegionCoordinates {
542 x: f32,
545 y: f32,
548 z: f32,
552}
553
554#[cfg(feature = "chumsky")]
562#[must_use]
563pub fn region_coordinates_parser<'src>()
564-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
565{
566 just('{')
567 .ignore_then(whitespace().or_not())
568 .ignore_then(f32_parser())
569 .then_ignore(just(','))
570 .then_ignore(whitespace().or_not())
571 .then(f32_parser())
572 .then_ignore(just(','))
573 .then_ignore(whitespace().or_not())
574 .then(f32_parser())
575 .then_ignore(whitespace().or_not())
576 .then_ignore(just('}'))
577 .map(|((x, y), z)| RegionCoordinates::new(x, y, z))
578}
579
580impl RegionCoordinates {
581 #[must_use]
583 pub const fn new(x: f32, y: f32, z: f32) -> Self {
584 Self { x, y, z }
585 }
586
587 #[must_use]
589 pub const fn x(&self) -> f32 {
590 self.x
591 }
592
593 #[must_use]
595 pub const fn y(&self) -> f32 {
596 self.y
597 }
598
599 #[must_use]
601 pub const fn z(&self) -> f32 {
602 self.z
603 }
604
605 #[must_use]
607 pub fn in_bounds(&self) -> bool {
608 self.x >= 0f32
609 && self.x < 256f32
610 && self.y >= 0f32
611 && self.y < 256f32
612 && self.z >= 0f32
613 && self.z < 4096f32
614 }
615}
616
617impl From<crate::lsl::Vector> for RegionCoordinates {
618 fn from(value: crate::lsl::Vector) -> Self {
619 Self {
620 x: value.x,
621 y: value.y,
622 z: value.z,
623 }
624 }
625}
626
627#[nutype::nutype(
629 sanitize(trim),
630 validate(len_char_min = 2, len_char_max = 35),
631 derive(
632 Debug,
633 Clone,
634 Display,
635 Hash,
636 PartialEq,
637 Eq,
638 PartialOrd,
639 Ord,
640 Serialize,
641 Deserialize,
642 AsRef
643 )
644)]
645pub struct RegionName(String);
646
647#[cfg(feature = "chumsky")]
653#[must_use]
654pub fn url_region_name_parser<'src>()
655-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
656 url_text_component_parser().try_map(|region_name, span| {
657 RegionName::try_new(region_name).map_err(|err| chumsky::error::Rich::custom(span, err))
658 })
659}
660
661#[cfg(feature = "chumsky")]
667#[must_use]
668pub fn region_name_parser<'src>()
669-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
670 any()
671 .filter(|c: &char| {
672 c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
673 })
674 .repeated()
675 .at_least(2)
676 .collect::<String>()
677 .try_map(|region_name, span| {
678 RegionName::try_new(region_name).map_err(|err| chumsky::error::Rich::custom(span, err))
679 })
680}
681
682#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
686pub struct Location {
687 pub region_name: RegionName,
689 pub x: u8,
691 pub y: u8,
693 pub z: u16,
695}
696
697#[cfg(feature = "chumsky")]
703#[must_use]
704pub fn location_parser<'src>()
705-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
706 region_name_parser()
707 .then_ignore(just('/'))
708 .then(u8_parser())
709 .then_ignore(just('/'))
710 .then(u8_parser())
711 .then_ignore(just('/'))
712 .then(u16_parser())
713 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
714}
715
716#[cfg(feature = "chumsky")]
723#[must_use]
724pub fn url_location_parser<'src>()
725-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
726 url_region_name_parser()
727 .then_ignore(just('/'))
728 .then(u8_parser())
729 .then_ignore(just('/'))
730 .then(u8_parser())
731 .then_ignore(just('/'))
732 .then(u16_parser())
733 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
734}
735
736#[cfg(feature = "chumsky")]
743#[must_use]
744pub fn url_encoded_location_parser<'src>()
745-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
746 url_text_component_parser().try_map(|s, span| {
747 location_parser().parse(&s).into_result().map_err(|err| {
748 chumsky::error::Rich::custom(
749 span,
750 format!("Parsing {s} as location failed with: {err:#?}"),
751 )
752 })
753 })
754}
755
756#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
758pub enum LocationParseError {
759 #[error(
761 "unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
762 )]
763 UnexpectedComponentCount(String, usize),
764 #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
766 UnexpectedScheme(String, String),
767 #[error(
769 "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
770 )]
771 UnexpectedNonEmptySecondComponent(String, String),
772 #[error(
774 "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
775 )]
776 UnexpectedHost(String, String),
777 #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
779 UnexpectedPath(String, String),
780 #[error("error parsing the region name {0}: {1}")]
782 RegionName(String, RegionNameError),
783 #[error("error parsing the X coordinate {0}: {1}")]
785 X(String, std::num::ParseIntError),
786 #[error("error parsing the Y coordinate {0}: {1}")]
788 Y(String, std::num::ParseIntError),
789 #[error("error parsing the Z coordinate {0}: {1}")]
791 Z(String, std::num::ParseIntError),
792}
793
794impl std::str::FromStr for Location {
795 type Err = LocationParseError;
796
797 fn from_str(s: &str) -> Result<Self, Self::Err> {
798 let usb_location = s
800 .split_once(',')
801 .map_or(s, |(usb_location, _usb_comment)| usb_location);
802 let parts = usb_location.split('/').collect::<Vec<_>>();
803 if let [region_name, x, y, z] = parts.as_slice() {
804 let region_name = RegionName::try_new(region_name.replace("%20", " "))
805 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
806 let x = x
807 .parse()
808 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
809 let y = y
810 .parse()
811 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
812 let z = z
813 .parse()
814 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
815 return Ok(Self {
816 region_name,
817 x,
818 y,
819 z,
820 });
821 }
822 if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
823 if *scheme != "http:" && *scheme != "https:" {
824 return Err(LocationParseError::UnexpectedScheme(
825 s.to_owned(),
826 scheme.to_string(),
827 ));
828 }
829 if !second_component.is_empty() {
830 return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
831 s.to_owned(),
832 second_component.to_string(),
833 ));
834 }
835 if *host != "maps.secondlife.com" && *host != "slurl.com" {
836 return Err(LocationParseError::UnexpectedHost(
837 s.to_owned(),
838 host.to_string(),
839 ));
840 }
841 if *path != "secondlife" {
842 return Err(LocationParseError::UnexpectedPath(
843 s.to_owned(),
844 path.to_string(),
845 ));
846 }
847 let region_name = RegionName::try_new(region_name.replace("%20", " "))
848 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
849 let x = x
850 .parse()
851 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
852 let y = y
853 .parse()
854 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
855 let z = z
856 .parse()
857 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
858 return Ok(Self {
859 region_name,
860 x,
861 y,
862 z,
863 });
864 }
865 Err(LocationParseError::UnexpectedComponentCount(
866 s.to_owned(),
867 parts.len(),
868 ))
869 }
870}
871
872impl Location {
873 #[must_use]
875 pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
876 Self {
877 region_name,
878 x,
879 y,
880 z,
881 }
882 }
883
884 #[must_use]
886 pub const fn region_name(&self) -> &RegionName {
887 &self.region_name
888 }
889
890 #[must_use]
892 pub const fn x(&self) -> u8 {
893 self.x
894 }
895
896 #[must_use]
898 pub const fn y(&self) -> u8 {
899 self.y
900 }
901
902 #[must_use]
904 pub const fn z(&self) -> u16 {
905 self.z
906 }
907
908 #[must_use]
910 pub fn as_maps_url(&self) -> String {
911 format!(
912 "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
913 self.region_name, self.x, self.y, self.z
914 )
915 }
916}
917
918#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
923pub struct UnconstrainedLocation {
924 pub region_name: RegionName,
926 pub x: i16,
928 pub y: i16,
930 pub z: i32,
932}
933
934impl UnconstrainedLocation {
935 #[must_use]
937 pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
938 Self {
939 region_name,
940 x,
941 y,
942 z,
943 }
944 }
945
946 #[must_use]
948 pub const fn region_name(&self) -> &RegionName {
949 &self.region_name
950 }
951
952 #[must_use]
954 pub const fn x(&self) -> i16 {
955 self.x
956 }
957
958 #[must_use]
960 pub const fn y(&self) -> i16 {
961 self.y
962 }
963
964 #[must_use]
966 pub const fn z(&self) -> i32 {
967 self.z
968 }
969}
970
971#[cfg(feature = "chumsky")]
977#[must_use]
978pub fn unconstrained_location_parser<'src>() -> impl Parser<
979 'src,
980 &'src str,
981 UnconstrainedLocation,
982 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
983> {
984 region_name_parser()
985 .then_ignore(just('/'))
986 .then(i16_parser())
987 .then_ignore(just('/'))
988 .then(i16_parser())
989 .then_ignore(just('/'))
990 .then(i32_parser())
991 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
992}
993
994#[cfg(feature = "chumsky")]
1001#[must_use]
1002pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1003 'src,
1004 &'src str,
1005 UnconstrainedLocation,
1006 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1007> {
1008 url_region_name_parser()
1009 .then_ignore(just('/'))
1010 .then(i16_parser())
1011 .then_ignore(just('/'))
1012 .then(i16_parser())
1013 .then_ignore(just('/'))
1014 .then(i32_parser())
1015 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1016}
1017
1018#[cfg(feature = "chumsky")]
1025#[must_use]
1026pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1027 'src,
1028 &'src str,
1029 UnconstrainedLocation,
1030 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1031> {
1032 url_region_name_parser()
1033 .then_ignore(just('/'))
1034 .then(i16_parser())
1035 .then_ignore(just('/'))
1036 .then(i16_parser())
1037 .then_ignore(just('/'))
1038 .then(i32_parser())
1039 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1040}
1041
1042impl TryFrom<UnconstrainedLocation> for Location {
1043 type Error = std::num::TryFromIntError;
1044
1045 fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1046 Ok(Self::new(
1047 value.region_name,
1048 value.x.try_into()?,
1049 value.y.try_into()?,
1050 value.z.try_into()?,
1051 ))
1052 }
1053}
1054
1055impl From<Location> for UnconstrainedLocation {
1056 fn from(value: Location) -> Self {
1057 Self {
1058 region_name: value.region_name,
1059 x: value.x.into(),
1060 y: value.y.into(),
1061 z: value.z.into(),
1062 }
1063 }
1064}
1065
1066#[nutype::nutype(
1068 validate(greater_or_equal = 1, less_or_equal = 8),
1069 derive(
1070 Debug,
1071 Clone,
1072 Copy,
1073 Display,
1074 FromStr,
1075 Hash,
1076 PartialEq,
1077 Eq,
1078 PartialOrd,
1079 Ord,
1080 Serialize,
1081 Deserialize
1082 )
1083)]
1084pub struct ZoomLevel(u8);
1085
1086#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1089pub enum ZoomFitError {
1090 #[error("region size in x direction can not be zero")]
1092 RegionSizeXZero,
1093
1094 #[error("region size in y direction can not be zero")]
1096 RegionSizeYZero,
1097
1098 #[error("output image size in x direction can not be zero")]
1100 OutputSizeXZero,
1101
1102 #[error("output image size in y direction can not be zero")]
1104 OutputSizeYZero,
1105
1106 #[error("error converting a logarithm value into a u8")]
1108 LogarithmConversionError(#[from] std::num::TryFromIntError),
1109
1110 #[error("error creating zoom level from calculated value")]
1113 ZoomLevelError(#[from] ZoomLevelError),
1114}
1115
1116impl ZoomLevel {
1117 #[must_use]
1122 pub fn tile_size(&self) -> u16 {
1123 let exponent: u32 = self.into_inner().into();
1124 let exponent = exponent.saturating_sub(1);
1125 2u16.pow(exponent)
1126 }
1127
1128 #[expect(
1133 clippy::arithmetic_side_effects,
1134 reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1135 )]
1136 #[must_use]
1137 pub fn tile_size_in_pixels(&self) -> u32 {
1138 let tile_size: u32 = self.tile_size().into();
1139 let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1140 tile_size * region_size_in_map_tile_in_pixels
1141 }
1142
1143 #[must_use]
1150 pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1151 let tile_size = self.tile_size();
1152 #[expect(
1153 clippy::arithmetic_side_effects,
1154 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)"
1155 )]
1156 GridCoordinates {
1157 x: x.saturating_sub(x % tile_size),
1158 y: y.saturating_sub(y % tile_size),
1159 }
1160 }
1161
1162 #[must_use]
1167 pub fn pixels_per_region(&self) -> u16 {
1168 let exponent: u32 = self.into_inner().into();
1169 let exponent = exponent.saturating_sub(1);
1170 let exponent = 8u32.saturating_sub(exponent);
1171 2u16.pow(exponent)
1172 }
1173
1174 #[must_use]
1176 pub fn pixels_per_meter(&self) -> f32 {
1177 f32::from(self.pixels_per_region()) / 256f32
1178 }
1179
1180 pub fn max_zoom_level_to_fit_regions_into_output_image(
1190 region_x: u16,
1191 region_y: u16,
1192 output_x: u32,
1193 output_y: u32,
1194 ) -> Result<Self, ZoomFitError> {
1195 if region_x == 0 {
1196 return Err(ZoomFitError::RegionSizeXZero);
1197 }
1198 if region_y == 0 {
1199 return Err(ZoomFitError::RegionSizeYZero);
1200 }
1201 if output_x == 0 {
1202 return Err(ZoomFitError::OutputSizeXZero);
1203 }
1204 if output_y == 0 {
1205 return Err(ZoomFitError::OutputSizeYZero);
1206 }
1207 let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x.into());
1208 let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y.into());
1209 let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1210 8,
1211 output_pixels_per_region_x
1212 .ilog2()
1213 .try_into()
1214 .map_err(ZoomFitError::LogarithmConversionError)?,
1215 ));
1216 let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1217 8,
1218 output_pixels_per_region_y
1219 .ilog2()
1220 .try_into()
1221 .map_err(ZoomFitError::LogarithmConversionError)?,
1222 ));
1223 Ok(Self::try_new(std::cmp::max(
1224 max_zoom_level_x,
1225 max_zoom_level_y,
1226 ))?)
1227 }
1228}
1229
1230#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1232#[expect(
1233 clippy::module_name_repetitions,
1234 reason = "the type is used outside this module"
1235)]
1236pub struct MapTileDescriptor {
1237 zoom_level: ZoomLevel,
1239 lower_left_corner: GridCoordinates,
1241}
1242
1243impl MapTileDescriptor {
1244 #[must_use]
1249 pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1250 let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1251 Self {
1252 zoom_level,
1253 lower_left_corner,
1254 }
1255 }
1256
1257 #[must_use]
1259 pub const fn zoom_level(&self) -> &ZoomLevel {
1260 &self.zoom_level
1261 }
1262
1263 #[must_use]
1265 pub const fn lower_left_corner(&self) -> &GridCoordinates {
1266 &self.lower_left_corner
1267 }
1268
1269 #[must_use]
1271 pub fn tile_size(&self) -> u16 {
1272 self.zoom_level.tile_size()
1273 }
1274
1275 #[must_use]
1277 pub fn tile_size_in_pixels(&self) -> u32 {
1278 self.zoom_level.tile_size_in_pixels()
1279 }
1280
1281 #[must_use]
1283 pub fn grid_rectangle(&self) -> GridRectangle {
1284 GridRectangle::new(
1285 self.lower_left_corner,
1286 GridCoordinates::new(
1287 self.lower_left_corner
1288 .x()
1289 .saturating_add(self.zoom_level.tile_size())
1290 .saturating_sub(1),
1291 self.lower_left_corner
1292 .y()
1293 .saturating_add(self.zoom_level.tile_size())
1294 .saturating_sub(1),
1295 ),
1296 )
1297 }
1298}
1299
1300#[derive(Debug, Clone)]
1302pub struct USBWaypoint {
1303 location: Location,
1305 comment: Option<String>,
1307}
1308
1309impl USBWaypoint {
1310 #[must_use]
1312 pub const fn new(location: Location, comment: Option<String>) -> Self {
1313 Self { location, comment }
1314 }
1315
1316 #[must_use]
1318 pub const fn location(&self) -> &Location {
1319 &self.location
1320 }
1321
1322 #[must_use]
1324 pub fn region_coordinates(&self) -> RegionCoordinates {
1325 RegionCoordinates::new(
1326 f32::from(self.location.x()),
1327 f32::from(self.location.y()),
1328 f32::from(self.location.z()),
1329 )
1330 }
1331
1332 #[must_use]
1334 pub const fn comment(&self) -> Option<&String> {
1335 self.comment.as_ref()
1336 }
1337}
1338
1339impl std::fmt::Display for USBWaypoint {
1340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1341 write!(f, "{}", self.location.as_maps_url())?;
1342 if let Some(comment) = &self.comment {
1343 write!(f, ",{comment}")?;
1344 }
1345 Ok(())
1346 }
1347}
1348
1349impl std::str::FromStr for USBWaypoint {
1350 type Err = LocationParseError;
1351
1352 fn from_str(s: &str) -> Result<Self, Self::Err> {
1353 if let Some((location, comment)) = s.split_once(',') {
1354 Ok(Self {
1355 location: location.parse()?,
1356 comment: Some(comment.to_owned()),
1357 })
1358 } else {
1359 Ok(Self {
1360 location: s.parse()?,
1361 comment: None,
1362 })
1363 }
1364 }
1365}
1366
1367#[derive(Debug, Clone)]
1369pub struct USBNotecard {
1370 waypoints: Vec<USBWaypoint>,
1372}
1373
1374#[derive(Debug, thiserror::Error, strum::EnumIs)]
1376pub enum USBNotecardLoadError {
1377 #[error("I/O error opening or reading the file: {0}")]
1379 Io(#[from] std::io::Error),
1380 #[error("parse error deserializing the USB notecard lines: {0}")]
1382 LocationParseError(#[from] LocationParseError),
1383}
1384
1385impl USBNotecard {
1386 #[must_use]
1388 pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1389 Self { waypoints }
1390 }
1391
1392 #[must_use]
1394 pub fn waypoints(&self) -> &[USBWaypoint] {
1395 &self.waypoints
1396 }
1397
1398 pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1405 let contents = std::fs::read_to_string(filename)?;
1406 Ok(contents.parse()?)
1407 }
1408}
1409
1410impl std::fmt::Display for USBNotecard {
1411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1412 for waypoint in &self.waypoints {
1413 writeln!(f, "{waypoint}")?;
1414 }
1415 Ok(())
1416 }
1417}
1418
1419impl std::str::FromStr for USBNotecard {
1420 type Err = LocationParseError;
1421
1422 fn from_str(s: &str) -> Result<Self, Self::Err> {
1423 s.lines()
1424 .map(|line| line.parse::<USBWaypoint>())
1425 .collect::<Result<Vec<_>, _>>()
1426 .map(|waypoints| Self { waypoints })
1427 }
1428}
1429
1430#[cfg(test)]
1431mod test {
1432 use super::*;
1433 use pretty_assertions::assert_eq;
1434
1435 #[test]
1436 fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1437 assert_eq!(
1438 "Beach%20Valley/110/67/24".parse::<Location>(),
1439 Ok(Location {
1440 region_name: RegionName::try_new("Beach Valley")?,
1441 x: 110,
1442 y: 67,
1443 z: 24
1444 }),
1445 );
1446 Ok(())
1447 }
1448
1449 #[test]
1450 fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1451 assert_eq!(
1452 "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1453 Ok(Location {
1454 region_name: RegionName::try_new("Beach Valley")?,
1455 x: 110,
1456 y: 67,
1457 z: 24
1458 }),
1459 );
1460 Ok(())
1461 }
1462
1463 #[test]
1464 fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1465 assert_eq!(
1466 "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1467 Ok(Location {
1468 region_name: RegionName::try_new("Beach Valley")?,
1469 x: 110,
1470 y: 67,
1471 z: 24
1472 }),
1473 );
1474 Ok(())
1475 }
1476
1477 #[test]
1478 fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
1479 assert_eq!(
1480 "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
1481 Ok(Location {
1482 region_name: RegionName::try_new("Beach Valley")?,
1483 x: 110,
1484 y: 67,
1485 z: 24
1486 }),
1487 );
1488 Ok(())
1489 }
1490
1491 #[test]
1492 fn test_grid_rectangle_intersection_upper_right_corner()
1493 -> Result<(), Box<dyn std::error::Error>> {
1494 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1495 let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
1496 assert_eq!(
1497 rect1.intersect(&rect2),
1498 Some(GridRectangle::new(
1499 GridCoordinates::new(15, 15),
1500 GridCoordinates::new(20, 20),
1501 ))
1502 );
1503 Ok(())
1504 }
1505
1506 #[test]
1507 fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
1508 {
1509 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1510 let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
1511 assert_eq!(
1512 rect1.intersect(&rect2),
1513 Some(GridRectangle::new(
1514 GridCoordinates::new(10, 15),
1515 GridCoordinates::new(15, 20),
1516 ))
1517 );
1518 Ok(())
1519 }
1520
1521 #[test]
1522 fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
1523 {
1524 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1525 let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
1526 assert_eq!(
1527 rect1.intersect(&rect2),
1528 Some(GridRectangle::new(
1529 GridCoordinates::new(10, 10),
1530 GridCoordinates::new(15, 15),
1531 ))
1532 );
1533 Ok(())
1534 }
1535
1536 #[test]
1537 fn test_grid_rectangle_intersection_lower_right_corner()
1538 -> Result<(), Box<dyn std::error::Error>> {
1539 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1540 let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
1541 assert_eq!(
1542 rect1.intersect(&rect2),
1543 Some(GridRectangle::new(
1544 GridCoordinates::new(15, 10),
1545 GridCoordinates::new(20, 15),
1546 ))
1547 );
1548 Ok(())
1549 }
1550
1551 #[test]
1552 fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
1553 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1554 let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
1555 assert_eq!(rect1.intersect(&rect2), None);
1556 Ok(())
1557 }
1558
1559 #[cfg(feature = "chumsky")]
1560 #[test]
1561 fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1562 let region_name = "Viterbo";
1563 assert_eq!(
1564 url_region_name_parser().parse(region_name).into_result(),
1565 Ok(RegionName::try_new(region_name)?)
1566 );
1567 Ok(())
1568 }
1569
1570 #[cfg(feature = "chumsky")]
1571 #[test]
1572 fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1573 let region_name = "Da Boom";
1574 let input = region_name.replace(' ', "%20");
1575 assert_eq!(
1576 url_region_name_parser().parse(&input).into_result(),
1577 Ok(RegionName::try_new(region_name)?)
1578 );
1579 Ok(())
1580 }
1581
1582 #[cfg(feature = "chumsky")]
1583 #[test]
1584 fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1585 let region_name = "Da Boom";
1586 assert_eq!(
1587 region_name_parser().parse(region_name).into_result(),
1588 Ok(RegionName::try_new(region_name)?)
1589 );
1590 Ok(())
1591 }
1592
1593 #[cfg(feature = "chumsky")]
1594 #[test]
1595 fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1596 let region_name = "Viterbo";
1597 let input = format!("{region_name}/1/2/300");
1598 assert_eq!(
1599 url_location_parser().parse(&input).into_result(),
1600 Ok(Location {
1601 region_name: RegionName::try_new(region_name)?,
1602 x: 1,
1603 y: 2,
1604 z: 300
1605 })
1606 );
1607 Ok(())
1608 }
1609
1610 #[cfg(feature = "chumsky")]
1611 #[test]
1612 fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1613 let region_name = "Da Boom";
1614 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1615 assert_eq!(
1616 url_location_parser().parse(&input).into_result(),
1617 Ok(Location {
1618 region_name: RegionName::try_new(region_name)?,
1619 x: 1,
1620 y: 2,
1621 z: 300
1622 })
1623 );
1624 Ok(())
1625 }
1626
1627 #[cfg(feature = "chumsky")]
1628 #[test]
1629 fn test_url_location_parser_url_whitespace_single_digit_after_space()
1630 -> Result<(), Box<dyn std::error::Error>> {
1631 let region_name = "Foo Bar 3";
1632 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1633 assert_eq!(
1634 url_location_parser().parse(&input).into_result(),
1635 Ok(Location {
1636 region_name: RegionName::try_new(region_name)?,
1637 x: 1,
1638 y: 2,
1639 z: 300
1640 })
1641 );
1642 Ok(())
1643 }
1644
1645 #[cfg(feature = "chumsky")]
1646 #[test]
1647 fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
1648 assert_eq!(
1649 region_coordinates_parser()
1650 .parse("{ 63.0486, 45.2515, 1501.08 }")
1651 .into_result(),
1652 Ok(RegionCoordinates {
1653 x: 63.0486,
1654 y: 45.2515,
1655 z: 1501.08,
1656 })
1657 );
1658 Ok(())
1659 }
1660}