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 #[must_use]
310 #[expect(
311 clippy::arithmetic_side_effects,
312 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
313 )]
314 pub fn expanded_west(&self, by: u16) -> Self {
315 Self::new(
316 self.lower_left_corner.to_owned() + GridCoordinateOffset::new(-i32::from(by), 0),
317 self.upper_right_corner.to_owned(),
318 )
319 }
320
321 #[must_use]
325 #[expect(
326 clippy::arithmetic_side_effects,
327 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
328 )]
329 pub fn expanded_east(&self, by: u16) -> Self {
330 Self::new(
331 self.lower_left_corner.to_owned(),
332 self.upper_right_corner.to_owned() + GridCoordinateOffset::new(i32::from(by), 0),
333 )
334 }
335
336 #[must_use]
340 #[expect(
341 clippy::arithmetic_side_effects,
342 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
343 )]
344 pub fn expanded_south(&self, by: u16) -> Self {
345 Self::new(
346 self.lower_left_corner.to_owned() + GridCoordinateOffset::new(0, -i32::from(by)),
347 self.upper_right_corner.to_owned(),
348 )
349 }
350
351 #[must_use]
355 #[expect(
356 clippy::arithmetic_side_effects,
357 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
358 )]
359 pub fn expanded_north(&self, by: u16) -> Self {
360 Self::new(
361 self.lower_left_corner.to_owned(),
362 self.upper_right_corner.to_owned() + GridCoordinateOffset::new(0, i32::from(by)),
363 )
364 }
365}
366
367pub trait GridRectangleLike {
370 #[must_use]
372 fn grid_rectangle(&self) -> GridRectangle;
373
374 #[must_use]
376 fn lower_left_corner(&self) -> GridCoordinates {
377 self.grid_rectangle().lower_left_corner().to_owned()
378 }
379
380 #[must_use]
382 fn lower_right_corner(&self) -> GridCoordinates {
383 GridCoordinates::new(
384 self.grid_rectangle().upper_right_corner().x(),
385 self.grid_rectangle().lower_left_corner().y(),
386 )
387 }
388
389 #[must_use]
391 fn upper_left_corner(&self) -> GridCoordinates {
392 GridCoordinates::new(
393 self.grid_rectangle().lower_left_corner().x(),
394 self.grid_rectangle().upper_right_corner().y(),
395 )
396 }
397
398 #[must_use]
400 fn upper_right_corner(&self) -> GridCoordinates {
401 self.grid_rectangle().upper_right_corner().to_owned()
402 }
403
404 #[must_use]
406 fn size_x(&self) -> u16 {
407 self.grid_rectangle().size_x()
408 }
409
410 #[must_use]
412 fn size_y(&self) -> u16 {
413 self.grid_rectangle().size_y()
414 }
415
416 #[must_use]
418 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
419 self.lower_left_corner().x()..=self.upper_right_corner().x()
420 }
421
422 #[must_use]
424 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
425 self.lower_left_corner().y()..=self.upper_right_corner().y()
426 }
427
428 #[must_use]
430 fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
431 self.lower_left_corner().x() <= grid_coordinates.x()
432 && grid_coordinates.x() <= self.upper_right_corner().x()
433 && self.lower_left_corner().y() <= grid_coordinates.y()
434 && grid_coordinates.y() <= self.upper_right_corner().y()
435 }
436
437 #[must_use]
440 fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
441 where
442 O: GridRectangleLike,
443 {
444 let self_x_range: ranges::GenericRange<u16> = self.x_range().into();
445 let self_y_range: ranges::GenericRange<u16> = self.y_range().into();
446 let other_x_range: ranges::GenericRange<u16> = other.x_range().into();
447 let other_y_range: ranges::GenericRange<u16> = other.y_range().into();
448 let x_intersection = self_x_range.intersect(other_x_range);
449 let y_intersection = self_y_range.intersect(other_y_range);
450 match (x_intersection, y_intersection) {
451 (
452 ranges::OperationResult::Single(x_range),
453 ranges::OperationResult::Single(y_range),
454 ) => {
455 use std::ops::Bound;
456 use std::ops::RangeBounds as _;
457 match (
458 x_range.start_bound(),
459 x_range.end_bound(),
460 y_range.start_bound(),
461 y_range.end_bound(),
462 ) {
463 (
464 Bound::Included(start_x),
465 Bound::Included(end_x),
466 Bound::Included(start_y),
467 Bound::Included(end_y),
468 ) => Some(GridRectangle::new(
469 GridCoordinates::new(*start_x, *start_y),
470 GridCoordinates::new(*end_x, *end_y),
471 )),
472 _ => None,
473 }
474 }
475 _ => None,
476 }
477 }
478
479 #[must_use]
490 fn pps_hud_config(&self) -> String {
491 let lower_left_corner_x = 256f32 * f32::from(self.lower_left_corner().x());
492 let lower_left_corner_y = 256f32 * f32::from(self.lower_left_corner().y());
493 format!(
498 "<{lower_left_corner_x},{lower_left_corner_y},0>/{}/{}/1",
499 f32::from(self.size_x()),
500 f32::from(self.size_y())
501 )
502 }
503}
504
505impl GridRectangleLike for GridRectangle {
506 fn grid_rectangle(&self) -> GridRectangle {
507 self.to_owned()
508 }
509
510 fn lower_left_corner(&self) -> GridCoordinates {
511 self.lower_left_corner.to_owned()
512 }
513
514 fn upper_right_corner(&self) -> GridCoordinates {
515 self.upper_right_corner.to_owned()
516 }
517
518 fn size_x(&self) -> u16 {
519 self.upper_right_corner
520 .x()
521 .saturating_sub(self.lower_left_corner().x())
522 .saturating_add(1)
523 }
524
525 fn size_y(&self) -> u16 {
526 self.upper_right_corner
527 .y()
528 .saturating_sub(self.lower_left_corner().y())
529 .saturating_add(1)
530 }
531
532 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
533 self.lower_left_corner.x()..=self.upper_right_corner.x()
534 }
535
536 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
537 self.lower_left_corner.y()..=self.upper_right_corner.y()
538 }
539}
540
541impl GridRectangleLike for MapTileDescriptor {
542 fn grid_rectangle(&self) -> GridRectangle {
543 GridRectangle::new(
544 self.lower_left_corner,
545 GridCoordinates::new(
546 self.lower_left_corner
547 .x()
548 .saturating_add(self.zoom_level.tile_size())
549 .saturating_sub(1),
550 self.lower_left_corner
551 .y()
552 .saturating_add(self.zoom_level.tile_size())
553 .saturating_sub(1),
554 ),
555 )
556 }
557}
558
559pub trait GridCoordinatesExt {
561 fn bounding_rectangle(&self) -> Option<GridRectangle>;
567}
568
569impl GridCoordinatesExt for Vec<GridCoordinates> {
570 fn bounding_rectangle(&self) -> Option<GridRectangle> {
571 if self.is_empty() {
572 return None;
573 }
574 let (xs, ys): (Vec<u16>, Vec<u16>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
575 #[expect(
577 clippy::unwrap_used,
578 reason = "we checked above that the container is non-empty"
579 )]
580 let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
581 #[expect(
582 clippy::unwrap_used,
583 reason = "we checked above that the container is non-empty"
584 )]
585 let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
586 Some(GridRectangle {
587 lower_left_corner: GridCoordinates::new(*min_x, *min_y),
588 upper_right_corner: GridCoordinates::new(*max_x, *max_y),
589 })
590 }
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
601pub struct RegionCoordinates {
602 x: f32,
605 y: f32,
608 z: f32,
612}
613
614#[cfg(feature = "chumsky")]
622#[must_use]
623pub fn region_coordinates_parser<'src>()
624-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
625{
626 just('{')
627 .ignore_then(whitespace().or_not())
628 .ignore_then(f32_parser())
629 .then_ignore(just(','))
630 .then_ignore(whitespace().or_not())
631 .then(f32_parser())
632 .then_ignore(just(','))
633 .then_ignore(whitespace().or_not())
634 .then(f32_parser())
635 .then_ignore(whitespace().or_not())
636 .then_ignore(just('}'))
637 .map(|((x, y), z)| RegionCoordinates::new(x, y, z))
638}
639
640impl RegionCoordinates {
641 #[must_use]
643 pub const fn new(x: f32, y: f32, z: f32) -> Self {
644 Self { x, y, z }
645 }
646
647 #[must_use]
649 pub const fn x(&self) -> f32 {
650 self.x
651 }
652
653 #[must_use]
655 pub const fn y(&self) -> f32 {
656 self.y
657 }
658
659 #[must_use]
661 pub const fn z(&self) -> f32 {
662 self.z
663 }
664
665 #[must_use]
667 pub fn in_bounds(&self) -> bool {
668 self.x >= 0f32
669 && self.x < 256f32
670 && self.y >= 0f32
671 && self.y < 256f32
672 && self.z >= 0f32
673 && self.z < 4096f32
674 }
675}
676
677impl From<crate::lsl::Vector> for RegionCoordinates {
678 fn from(value: crate::lsl::Vector) -> Self {
679 Self {
680 x: value.x,
681 y: value.y,
682 z: value.z,
683 }
684 }
685}
686
687#[nutype::nutype(
689 sanitize(trim),
690 validate(len_char_min = 2, len_char_max = 35),
691 derive(
692 Debug,
693 Clone,
694 Display,
695 Hash,
696 PartialEq,
697 Eq,
698 PartialOrd,
699 Ord,
700 Serialize,
701 Deserialize,
702 AsRef
703 )
704)]
705pub struct RegionName(String);
706
707#[cfg(feature = "chumsky")]
713#[must_use]
714pub fn url_region_name_parser<'src>()
715-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
716 url_text_component_parser().try_map(|region_name, span| {
717 RegionName::try_new(®ion_name).map_err(|err| {
718 chumsky::error::Rich::custom(
719 span,
720 format!("failed to parse url-encoded region name ({region_name}): {err:?}"),
721 )
722 })
723 })
724}
725
726#[cfg(feature = "chumsky")]
732#[must_use]
733pub fn region_name_parser<'src>()
734-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
735 any()
736 .filter(|c: &char| {
737 c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
738 })
739 .repeated()
740 .at_least(2)
741 .collect::<String>()
742 .try_map(|region_name, span| {
743 RegionName::try_new(®ion_name).map_err(|err| {
744 chumsky::error::Rich::custom(
745 span,
746 format!("failed to parse region name ({region_name}): {err:?}"),
747 )
748 })
749 })
750}
751
752#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
756pub struct Location {
757 pub region_name: RegionName,
759 pub x: u8,
761 pub y: u8,
763 pub z: u16,
765}
766
767#[cfg(feature = "chumsky")]
773#[must_use]
774pub fn location_parser<'src>()
775-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
776 region_name_parser()
777 .then_ignore(just('/'))
778 .then(u8_parser())
779 .then_ignore(just('/'))
780 .then(u8_parser())
781 .then_ignore(just('/'))
782 .then(u16_parser())
783 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
784}
785
786#[cfg(feature = "chumsky")]
793#[must_use]
794pub fn url_location_parser<'src>()
795-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
796 url_region_name_parser()
797 .then_ignore(just('/'))
798 .then(u8_parser())
799 .then_ignore(just('/'))
800 .then(u8_parser())
801 .then_ignore(just('/'))
802 .then(u16_parser())
803 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
804}
805
806#[cfg(feature = "chumsky")]
813#[must_use]
814pub fn url_encoded_location_parser<'src>()
815-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
816 url_text_component_parser().try_map(|s, span| {
817 location_parser().parse(&s).into_result().map_err(|err| {
818 chumsky::error::Rich::custom(
819 span,
820 format!("Parsing {s} as location failed with: {err:#?}"),
821 )
822 })
823 })
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
828pub enum LocationParseError {
829 #[error(
831 "unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
832 )]
833 UnexpectedComponentCount(String, usize),
834 #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
836 UnexpectedScheme(String, String),
837 #[error(
839 "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
840 )]
841 UnexpectedNonEmptySecondComponent(String, String),
842 #[error(
844 "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
845 )]
846 UnexpectedHost(String, String),
847 #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
849 UnexpectedPath(String, String),
850 #[error("error parsing the region name {0}: {1}")]
852 RegionName(String, RegionNameError),
853 #[error("error parsing the X coordinate {0}: {1}")]
855 X(String, std::num::ParseIntError),
856 #[error("error parsing the Y coordinate {0}: {1}")]
858 Y(String, std::num::ParseIntError),
859 #[error("error parsing the Z coordinate {0}: {1}")]
861 Z(String, std::num::ParseIntError),
862}
863
864impl std::str::FromStr for Location {
865 type Err = LocationParseError;
866
867 fn from_str(s: &str) -> Result<Self, Self::Err> {
868 let usb_location = s
870 .split_once(',')
871 .map_or(s, |(usb_location, _usb_comment)| usb_location);
872 let parts = usb_location.split('/').collect::<Vec<_>>();
873 if let [region_name, x, y, z] = parts.as_slice() {
874 let region_name = RegionName::try_new(region_name.replace("%20", " "))
875 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
876 let x = x
877 .parse()
878 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
879 let y = y
880 .parse()
881 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
882 let z = z
883 .parse()
884 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
885 return Ok(Self {
886 region_name,
887 x,
888 y,
889 z,
890 });
891 }
892 if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
893 if *scheme != "http:" && *scheme != "https:" {
894 return Err(LocationParseError::UnexpectedScheme(
895 s.to_owned(),
896 scheme.to_string(),
897 ));
898 }
899 if !second_component.is_empty() {
900 return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
901 s.to_owned(),
902 second_component.to_string(),
903 ));
904 }
905 if *host != "maps.secondlife.com" && *host != "slurl.com" {
906 return Err(LocationParseError::UnexpectedHost(
907 s.to_owned(),
908 host.to_string(),
909 ));
910 }
911 if *path != "secondlife" {
912 return Err(LocationParseError::UnexpectedPath(
913 s.to_owned(),
914 path.to_string(),
915 ));
916 }
917 let region_name = RegionName::try_new(region_name.replace("%20", " "))
918 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
919 let x = x
920 .parse()
921 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
922 let y = y
923 .parse()
924 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
925 let z = z
926 .parse()
927 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
928 return Ok(Self {
929 region_name,
930 x,
931 y,
932 z,
933 });
934 }
935 Err(LocationParseError::UnexpectedComponentCount(
936 s.to_owned(),
937 parts.len(),
938 ))
939 }
940}
941
942impl Location {
943 #[must_use]
945 pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
946 Self {
947 region_name,
948 x,
949 y,
950 z,
951 }
952 }
953
954 #[must_use]
956 pub const fn region_name(&self) -> &RegionName {
957 &self.region_name
958 }
959
960 #[must_use]
962 pub const fn x(&self) -> u8 {
963 self.x
964 }
965
966 #[must_use]
968 pub const fn y(&self) -> u8 {
969 self.y
970 }
971
972 #[must_use]
974 pub const fn z(&self) -> u16 {
975 self.z
976 }
977
978 #[must_use]
980 pub fn as_maps_url(&self) -> String {
981 format!(
982 "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
983 self.region_name, self.x, self.y, self.z
984 )
985 }
986}
987
988#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
993pub struct UnconstrainedLocation {
994 pub region_name: RegionName,
996 pub x: i16,
998 pub y: i16,
1000 pub z: i32,
1002}
1003
1004impl UnconstrainedLocation {
1005 #[must_use]
1007 pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
1008 Self {
1009 region_name,
1010 x,
1011 y,
1012 z,
1013 }
1014 }
1015
1016 #[must_use]
1018 pub const fn region_name(&self) -> &RegionName {
1019 &self.region_name
1020 }
1021
1022 #[must_use]
1024 pub const fn x(&self) -> i16 {
1025 self.x
1026 }
1027
1028 #[must_use]
1030 pub const fn y(&self) -> i16 {
1031 self.y
1032 }
1033
1034 #[must_use]
1036 pub const fn z(&self) -> i32 {
1037 self.z
1038 }
1039}
1040
1041#[cfg(feature = "chumsky")]
1047#[must_use]
1048pub fn unconstrained_location_parser<'src>() -> impl Parser<
1049 'src,
1050 &'src str,
1051 UnconstrainedLocation,
1052 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1053> {
1054 region_name_parser()
1055 .then_ignore(just('/'))
1056 .then(i16_parser())
1057 .then_ignore(just('/'))
1058 .then(i16_parser())
1059 .then_ignore(just('/'))
1060 .then(i32_parser())
1061 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1062}
1063
1064#[cfg(feature = "chumsky")]
1071#[must_use]
1072pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1073 'src,
1074 &'src str,
1075 UnconstrainedLocation,
1076 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1077> {
1078 url_region_name_parser()
1079 .then_ignore(just('/'))
1080 .then(i16_parser())
1081 .then_ignore(just('/'))
1082 .then(i16_parser())
1083 .then_ignore(just('/'))
1084 .then(i32_parser())
1085 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1086}
1087
1088#[cfg(feature = "chumsky")]
1095#[must_use]
1096pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1097 'src,
1098 &'src str,
1099 UnconstrainedLocation,
1100 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1101> {
1102 url_region_name_parser()
1103 .then_ignore(just('/'))
1104 .then(i16_parser())
1105 .then_ignore(just('/'))
1106 .then(i16_parser())
1107 .then_ignore(just('/'))
1108 .then(i32_parser())
1109 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1110}
1111
1112impl TryFrom<UnconstrainedLocation> for Location {
1113 type Error = std::num::TryFromIntError;
1114
1115 fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1116 Ok(Self::new(
1117 value.region_name,
1118 value.x.try_into()?,
1119 value.y.try_into()?,
1120 value.z.try_into()?,
1121 ))
1122 }
1123}
1124
1125impl From<Location> for UnconstrainedLocation {
1126 fn from(value: Location) -> Self {
1127 Self {
1128 region_name: value.region_name,
1129 x: value.x.into(),
1130 y: value.y.into(),
1131 z: value.z.into(),
1132 }
1133 }
1134}
1135
1136#[nutype::nutype(
1138 validate(greater_or_equal = 1, less_or_equal = 8),
1139 derive(
1140 Debug,
1141 Clone,
1142 Copy,
1143 Display,
1144 FromStr,
1145 Hash,
1146 PartialEq,
1147 Eq,
1148 PartialOrd,
1149 Ord,
1150 Serialize,
1151 Deserialize
1152 )
1153)]
1154pub struct ZoomLevel(u8);
1155
1156#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1159pub enum ZoomFitError {
1160 #[error("region size in x direction can not be zero")]
1162 RegionSizeXZero,
1163
1164 #[error("region size in y direction can not be zero")]
1166 RegionSizeYZero,
1167
1168 #[error("output image size in x direction can not be zero")]
1170 OutputSizeXZero,
1171
1172 #[error("output image size in y direction can not be zero")]
1174 OutputSizeYZero,
1175
1176 #[error("error converting a logarithm value into a u8")]
1178 LogarithmConversionError(#[from] std::num::TryFromIntError),
1179
1180 #[error("error creating zoom level from calculated value")]
1183 ZoomLevelError(#[from] ZoomLevelError),
1184}
1185
1186impl ZoomLevel {
1187 #[must_use]
1192 pub fn tile_size(&self) -> u16 {
1193 let exponent: u32 = self.into_inner().into();
1194 let exponent = exponent.saturating_sub(1);
1195 2u16.pow(exponent)
1196 }
1197
1198 #[expect(
1203 clippy::arithmetic_side_effects,
1204 reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1205 )]
1206 #[must_use]
1207 pub fn tile_size_in_pixels(&self) -> u32 {
1208 let tile_size: u32 = self.tile_size().into();
1209 let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1210 tile_size * region_size_in_map_tile_in_pixels
1211 }
1212
1213 #[must_use]
1220 pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1221 let tile_size = self.tile_size();
1222 #[expect(
1223 clippy::arithmetic_side_effects,
1224 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)"
1225 )]
1226 GridCoordinates {
1227 x: x.saturating_sub(x % tile_size),
1228 y: y.saturating_sub(y % tile_size),
1229 }
1230 }
1231
1232 #[must_use]
1237 pub fn pixels_per_region(&self) -> u16 {
1238 let exponent: u32 = self.into_inner().into();
1239 let exponent = exponent.saturating_sub(1);
1240 let exponent = 8u32.saturating_sub(exponent);
1241 2u16.pow(exponent)
1242 }
1243
1244 #[must_use]
1246 pub fn pixels_per_meter(&self) -> f32 {
1247 f32::from(self.pixels_per_region()) / 256f32
1248 }
1249
1250 pub fn max_zoom_level_to_fit_regions_into_output_image(
1260 region_x: u16,
1261 region_y: u16,
1262 output_x: u32,
1263 output_y: u32,
1264 ) -> Result<Self, ZoomFitError> {
1265 if region_x == 0 {
1266 return Err(ZoomFitError::RegionSizeXZero);
1267 }
1268 if region_y == 0 {
1269 return Err(ZoomFitError::RegionSizeYZero);
1270 }
1271 if output_x == 0 {
1272 return Err(ZoomFitError::OutputSizeXZero);
1273 }
1274 if output_y == 0 {
1275 return Err(ZoomFitError::OutputSizeYZero);
1276 }
1277 let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x.into());
1278 let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y.into());
1279 let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1280 8,
1281 output_pixels_per_region_x
1282 .ilog2()
1283 .try_into()
1284 .map_err(ZoomFitError::LogarithmConversionError)?,
1285 ));
1286 let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1287 8,
1288 output_pixels_per_region_y
1289 .ilog2()
1290 .try_into()
1291 .map_err(ZoomFitError::LogarithmConversionError)?,
1292 ));
1293 Ok(Self::try_new(std::cmp::max(
1294 max_zoom_level_x,
1295 max_zoom_level_y,
1296 ))?)
1297 }
1298}
1299
1300#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1302#[expect(
1303 clippy::module_name_repetitions,
1304 reason = "the type is used outside this module"
1305)]
1306pub struct MapTileDescriptor {
1307 zoom_level: ZoomLevel,
1309 lower_left_corner: GridCoordinates,
1311}
1312
1313impl MapTileDescriptor {
1314 #[must_use]
1319 pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1320 let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1321 Self {
1322 zoom_level,
1323 lower_left_corner,
1324 }
1325 }
1326
1327 #[must_use]
1329 pub const fn zoom_level(&self) -> &ZoomLevel {
1330 &self.zoom_level
1331 }
1332
1333 #[must_use]
1335 pub const fn lower_left_corner(&self) -> &GridCoordinates {
1336 &self.lower_left_corner
1337 }
1338
1339 #[must_use]
1341 pub fn tile_size(&self) -> u16 {
1342 self.zoom_level.tile_size()
1343 }
1344
1345 #[must_use]
1347 pub fn tile_size_in_pixels(&self) -> u32 {
1348 self.zoom_level.tile_size_in_pixels()
1349 }
1350
1351 #[must_use]
1353 pub fn grid_rectangle(&self) -> GridRectangle {
1354 GridRectangle::new(
1355 self.lower_left_corner,
1356 GridCoordinates::new(
1357 self.lower_left_corner
1358 .x()
1359 .saturating_add(self.zoom_level.tile_size())
1360 .saturating_sub(1),
1361 self.lower_left_corner
1362 .y()
1363 .saturating_add(self.zoom_level.tile_size())
1364 .saturating_sub(1),
1365 ),
1366 )
1367 }
1368}
1369
1370#[derive(Debug, Clone)]
1372pub struct USBWaypoint {
1373 location: Location,
1375 comment: Option<String>,
1377}
1378
1379impl USBWaypoint {
1380 #[must_use]
1382 pub const fn new(location: Location, comment: Option<String>) -> Self {
1383 Self { location, comment }
1384 }
1385
1386 #[must_use]
1388 pub const fn location(&self) -> &Location {
1389 &self.location
1390 }
1391
1392 #[must_use]
1394 pub fn region_coordinates(&self) -> RegionCoordinates {
1395 RegionCoordinates::new(
1396 f32::from(self.location.x()),
1397 f32::from(self.location.y()),
1398 f32::from(self.location.z()),
1399 )
1400 }
1401
1402 #[must_use]
1404 pub const fn comment(&self) -> Option<&String> {
1405 self.comment.as_ref()
1406 }
1407}
1408
1409impl std::fmt::Display for USBWaypoint {
1410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1411 write!(f, "{}", self.location.as_maps_url())?;
1412 if let Some(comment) = &self.comment {
1413 write!(f, ",{comment}")?;
1414 }
1415 Ok(())
1416 }
1417}
1418
1419impl std::str::FromStr for USBWaypoint {
1420 type Err = LocationParseError;
1421
1422 fn from_str(s: &str) -> Result<Self, Self::Err> {
1423 if let Some((location, comment)) = s.split_once(',') {
1424 Ok(Self {
1425 location: location.parse()?,
1426 comment: Some(comment.to_owned()),
1427 })
1428 } else {
1429 Ok(Self {
1430 location: s.parse()?,
1431 comment: None,
1432 })
1433 }
1434 }
1435}
1436
1437#[derive(Debug, Clone)]
1439pub struct USBNotecard {
1440 waypoints: Vec<USBWaypoint>,
1442}
1443
1444#[derive(Debug, thiserror::Error, strum::EnumIs)]
1446pub enum USBNotecardLoadError {
1447 #[error("I/O error opening or reading the file: {0}")]
1449 Io(#[from] std::io::Error),
1450 #[error("parse error deserializing the USB notecard lines: {0}")]
1452 LocationParseError(#[from] LocationParseError),
1453}
1454
1455impl USBNotecard {
1456 #[must_use]
1458 pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1459 Self { waypoints }
1460 }
1461
1462 #[must_use]
1464 pub fn waypoints(&self) -> &[USBWaypoint] {
1465 &self.waypoints
1466 }
1467
1468 pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1475 let contents = std::fs::read_to_string(filename)?;
1476 Ok(contents.parse()?)
1477 }
1478}
1479
1480impl std::fmt::Display for USBNotecard {
1481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1482 for waypoint in &self.waypoints {
1483 writeln!(f, "{waypoint}")?;
1484 }
1485 Ok(())
1486 }
1487}
1488
1489impl std::str::FromStr for USBNotecard {
1490 type Err = LocationParseError;
1491
1492 fn from_str(s: &str) -> Result<Self, Self::Err> {
1493 s.lines()
1494 .map(|line| line.parse::<USBWaypoint>())
1495 .collect::<Result<Vec<_>, _>>()
1496 .map(|waypoints| Self { waypoints })
1497 }
1498}
1499
1500#[cfg(test)]
1501mod test {
1502 use super::*;
1503 use pretty_assertions::assert_eq;
1504
1505 #[test]
1506 fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1507 assert_eq!(
1508 "Beach%20Valley/110/67/24".parse::<Location>(),
1509 Ok(Location {
1510 region_name: RegionName::try_new("Beach Valley")?,
1511 x: 110,
1512 y: 67,
1513 z: 24
1514 }),
1515 );
1516 Ok(())
1517 }
1518
1519 #[test]
1520 fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1521 assert_eq!(
1522 "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1523 Ok(Location {
1524 region_name: RegionName::try_new("Beach Valley")?,
1525 x: 110,
1526 y: 67,
1527 z: 24
1528 }),
1529 );
1530 Ok(())
1531 }
1532
1533 #[test]
1534 fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1535 assert_eq!(
1536 "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1537 Ok(Location {
1538 region_name: RegionName::try_new("Beach Valley")?,
1539 x: 110,
1540 y: 67,
1541 z: 24
1542 }),
1543 );
1544 Ok(())
1545 }
1546
1547 #[test]
1548 fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
1549 assert_eq!(
1550 "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
1551 Ok(Location {
1552 region_name: RegionName::try_new("Beach Valley")?,
1553 x: 110,
1554 y: 67,
1555 z: 24
1556 }),
1557 );
1558 Ok(())
1559 }
1560
1561 #[test]
1562 fn test_grid_rectangle_intersection_upper_right_corner()
1563 -> Result<(), Box<dyn std::error::Error>> {
1564 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1565 let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
1566 assert_eq!(
1567 rect1.intersect(&rect2),
1568 Some(GridRectangle::new(
1569 GridCoordinates::new(15, 15),
1570 GridCoordinates::new(20, 20),
1571 ))
1572 );
1573 Ok(())
1574 }
1575
1576 #[test]
1577 fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
1578 {
1579 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1580 let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
1581 assert_eq!(
1582 rect1.intersect(&rect2),
1583 Some(GridRectangle::new(
1584 GridCoordinates::new(10, 15),
1585 GridCoordinates::new(15, 20),
1586 ))
1587 );
1588 Ok(())
1589 }
1590
1591 #[test]
1592 fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
1593 {
1594 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1595 let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
1596 assert_eq!(
1597 rect1.intersect(&rect2),
1598 Some(GridRectangle::new(
1599 GridCoordinates::new(10, 10),
1600 GridCoordinates::new(15, 15),
1601 ))
1602 );
1603 Ok(())
1604 }
1605
1606 #[test]
1607 fn test_grid_rectangle_intersection_lower_right_corner()
1608 -> Result<(), Box<dyn std::error::Error>> {
1609 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1610 let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
1611 assert_eq!(
1612 rect1.intersect(&rect2),
1613 Some(GridRectangle::new(
1614 GridCoordinates::new(15, 10),
1615 GridCoordinates::new(20, 15),
1616 ))
1617 );
1618 Ok(())
1619 }
1620
1621 #[test]
1622 fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
1623 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1624 let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
1625 assert_eq!(rect1.intersect(&rect2), None);
1626 Ok(())
1627 }
1628
1629 #[test]
1630 fn test_grid_rectangle_expanded_west() {
1631 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1632 assert_eq!(rect.expanded_west(0), rect);
1633 assert_eq!(
1634 rect.expanded_west(3),
1635 GridRectangle::new(GridCoordinates::new(7, 10), GridCoordinates::new(20, 20)),
1636 );
1637 let near_edge =
1638 GridRectangle::new(GridCoordinates::new(2, 10), GridCoordinates::new(20, 20));
1639 assert_eq!(
1640 near_edge.expanded_west(5),
1641 GridRectangle::new(GridCoordinates::new(0, 10), GridCoordinates::new(20, 20)),
1642 );
1643 }
1644
1645 #[test]
1646 fn test_grid_rectangle_expanded_east() {
1647 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1648 assert_eq!(rect.expanded_east(0), rect);
1649 assert_eq!(
1650 rect.expanded_east(3),
1651 GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(23, 20)),
1652 );
1653 let near_edge = GridRectangle::new(
1654 GridCoordinates::new(10, 10),
1655 GridCoordinates::new(u16::MAX - 2, 20),
1656 );
1657 assert_eq!(
1658 near_edge.expanded_east(5),
1659 GridRectangle::new(
1660 GridCoordinates::new(10, 10),
1661 GridCoordinates::new(u16::MAX, 20),
1662 ),
1663 );
1664 }
1665
1666 #[test]
1667 fn test_grid_rectangle_expanded_south() {
1668 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1669 assert_eq!(rect.expanded_south(0), rect);
1670 assert_eq!(
1671 rect.expanded_south(3),
1672 GridRectangle::new(GridCoordinates::new(10, 7), GridCoordinates::new(20, 20)),
1673 );
1674 let near_edge =
1675 GridRectangle::new(GridCoordinates::new(10, 2), GridCoordinates::new(20, 20));
1676 assert_eq!(
1677 near_edge.expanded_south(5),
1678 GridRectangle::new(GridCoordinates::new(10, 0), GridCoordinates::new(20, 20)),
1679 );
1680 }
1681
1682 #[test]
1683 fn test_grid_rectangle_expanded_north() {
1684 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1685 assert_eq!(rect.expanded_north(0), rect);
1686 assert_eq!(
1687 rect.expanded_north(3),
1688 GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 23)),
1689 );
1690 let near_edge = GridRectangle::new(
1691 GridCoordinates::new(10, 10),
1692 GridCoordinates::new(20, u16::MAX - 2),
1693 );
1694 assert_eq!(
1695 near_edge.expanded_north(5),
1696 GridRectangle::new(
1697 GridCoordinates::new(10, 10),
1698 GridCoordinates::new(20, u16::MAX),
1699 ),
1700 );
1701 }
1702
1703 #[cfg(feature = "chumsky")]
1704 #[test]
1705 fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1706 let region_name = "Viterbo";
1707 assert_eq!(
1708 url_region_name_parser().parse(region_name).into_result(),
1709 Ok(RegionName::try_new(region_name)?)
1710 );
1711 Ok(())
1712 }
1713
1714 #[cfg(feature = "chumsky")]
1715 #[test]
1716 fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1717 let region_name = "Da Boom";
1718 let input = region_name.replace(' ', "%20");
1719 assert_eq!(
1720 url_region_name_parser().parse(&input).into_result(),
1721 Ok(RegionName::try_new(region_name)?)
1722 );
1723 Ok(())
1724 }
1725
1726 #[cfg(feature = "chumsky")]
1727 #[test]
1728 fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1729 let region_name = "Da Boom";
1730 assert_eq!(
1731 region_name_parser().parse(region_name).into_result(),
1732 Ok(RegionName::try_new(region_name)?)
1733 );
1734 Ok(())
1735 }
1736
1737 #[cfg(feature = "chumsky")]
1738 #[test]
1739 fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1740 let region_name = "Viterbo";
1741 let input = format!("{region_name}/1/2/300");
1742 assert_eq!(
1743 url_location_parser().parse(&input).into_result(),
1744 Ok(Location {
1745 region_name: RegionName::try_new(region_name)?,
1746 x: 1,
1747 y: 2,
1748 z: 300
1749 })
1750 );
1751 Ok(())
1752 }
1753
1754 #[cfg(feature = "chumsky")]
1755 #[test]
1756 fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1757 let region_name = "Da Boom";
1758 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1759 assert_eq!(
1760 url_location_parser().parse(&input).into_result(),
1761 Ok(Location {
1762 region_name: RegionName::try_new(region_name)?,
1763 x: 1,
1764 y: 2,
1765 z: 300
1766 })
1767 );
1768 Ok(())
1769 }
1770
1771 #[cfg(feature = "chumsky")]
1772 #[test]
1773 fn test_url_location_parser_url_whitespace_single_digit_after_space()
1774 -> Result<(), Box<dyn std::error::Error>> {
1775 let region_name = "Foo Bar 3";
1776 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1777 assert_eq!(
1778 url_location_parser().parse(&input).into_result(),
1779 Ok(Location {
1780 region_name: RegionName::try_new(region_name)?,
1781 x: 1,
1782 y: 2,
1783 z: 300
1784 })
1785 );
1786 Ok(())
1787 }
1788
1789 #[cfg(feature = "chumsky")]
1790 #[test]
1791 fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
1792 assert_eq!(
1793 region_coordinates_parser()
1794 .parse("{ 63.0486, 45.2515, 1501.08 }")
1795 .into_result(),
1796 Ok(RegionCoordinates {
1797 x: 63.0486,
1798 y: 45.2515,
1799 z: 1501.08,
1800 })
1801 );
1802 Ok(())
1803 }
1804}