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, u32_parser,
13 url_text_component_parser,
14};
15
16#[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
18pub struct Distance(f64);
19
20impl Distance {
21 #[must_use]
23 pub const fn new(meters: f64) -> Self {
24 Self(meters)
25 }
26
27 #[must_use]
29 pub const fn meters(&self) -> f64 {
30 self.0
31 }
32}
33
34impl std::fmt::Display for Distance {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "{} m", self.0)
37 }
38}
39
40impl std::ops::Add for Distance {
41 type Output = Self;
42
43 fn add(self, rhs: Self) -> Self::Output {
44 Self(self.0 + rhs.0)
45 }
46}
47
48impl std::ops::Sub for Distance {
49 type Output = Self;
50
51 fn sub(self, rhs: Self) -> Self::Output {
52 Self(self.0 - rhs.0)
53 }
54}
55
56impl std::ops::Mul<u8> for Distance {
57 type Output = Self;
58
59 fn mul(self, rhs: u8) -> Self::Output {
60 Self(self.0 * f64::from(rhs))
61 }
62}
63
64impl std::ops::Mul<u16> for Distance {
65 type Output = Self;
66
67 fn mul(self, rhs: u16) -> Self::Output {
68 Self(self.0 * f64::from(rhs))
69 }
70}
71
72impl std::ops::Mul<u32> for Distance {
73 type Output = Self;
74
75 fn mul(self, rhs: u32) -> Self::Output {
76 Self(self.0 * f64::from(rhs))
77 }
78}
79
80impl std::ops::Mul<f32> for Distance {
81 type Output = Self;
82
83 fn mul(self, rhs: f32) -> Self::Output {
84 Self(self.0 * f64::from(rhs))
85 }
86}
87
88impl std::ops::Mul<f64> for Distance {
89 type Output = Self;
90
91 fn mul(self, rhs: f64) -> Self::Output {
92 Self(self.0 * rhs)
93 }
94}
95
96impl std::ops::Div<u8> for Distance {
97 type Output = Self;
98
99 fn div(self, rhs: u8) -> Self::Output {
100 Self(self.0 / f64::from(rhs))
101 }
102}
103
104impl std::ops::Div<u16> for Distance {
105 type Output = Self;
106
107 fn div(self, rhs: u16) -> Self::Output {
108 Self(self.0 / f64::from(rhs))
109 }
110}
111
112impl std::ops::Div<u32> for Distance {
113 type Output = Self;
114
115 fn div(self, rhs: u32) -> Self::Output {
116 Self(self.0 / f64::from(rhs))
117 }
118}
119
120impl std::ops::Div<f32> for Distance {
121 type Output = Self;
122
123 fn div(self, rhs: f32) -> Self::Output {
124 Self(self.0 / f64::from(rhs))
125 }
126}
127
128impl std::ops::Div<f64> for Distance {
129 type Output = Self;
130
131 fn div(self, rhs: f64) -> Self::Output {
132 Self(self.0 / rhs)
133 }
134}
135
136impl std::ops::Div for Distance {
137 type Output = f64;
138
139 fn div(self, rhs: Self) -> Self::Output {
140 self.0 / rhs.0
141 }
142}
143
144impl std::ops::Rem<u8> for Distance {
145 type Output = Self;
146
147 fn rem(self, rhs: u8) -> Self::Output {
148 Self(self.0 % f64::from(rhs))
149 }
150}
151
152impl std::ops::Rem<u16> for Distance {
153 type Output = Self;
154
155 fn rem(self, rhs: u16) -> Self::Output {
156 Self(self.0 % f64::from(rhs))
157 }
158}
159
160impl std::ops::Rem<u32> for Distance {
161 type Output = Self;
162
163 fn rem(self, rhs: u32) -> Self::Output {
164 Self(self.0 % f64::from(rhs))
165 }
166}
167
168impl std::ops::Rem<f32> for Distance {
169 type Output = Self;
170
171 fn rem(self, rhs: f32) -> Self::Output {
172 Self(self.0 % f64::from(rhs))
173 }
174}
175
176impl std::ops::Rem<f64> for Distance {
177 type Output = Self;
178
179 fn rem(self, rhs: f64) -> Self::Output {
180 Self(self.0 % rhs)
181 }
182}
183
184#[cfg(feature = "chumsky")]
192#[must_use]
193pub fn distance_parser<'src>()
194-> impl Parser<'src, &'src str, Distance, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
195 crate::utils::unsigned_f64_parser()
196 .then_ignore(whitespace().or_not())
197 .then_ignore(just('m'))
198 .map(Distance)
199}
200
201#[derive(
211 Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
212)]
213pub struct LandArea(pub u32);
214
215impl LandArea {
216 pub const ZERO: Self = Self(0);
218
219 #[must_use]
221 pub const fn get(&self) -> u32 {
222 self.0
223 }
224}
225
226impl std::fmt::Display for LandArea {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 let Self(value) = self;
229 write!(f, "{value} m²")
230 }
231}
232
233impl std::ops::Add for LandArea {
234 type Output = Self;
235
236 fn add(self, rhs: Self) -> Self::Output {
237 let Self(lhs) = self;
238 let Self(rhs) = rhs;
239 #[expect(
240 clippy::arithmetic_side_effects,
241 reason = "the same overflow behaviour as the underlying integer addition, which is what a caller summing areas expects"
242 )]
243 Self(lhs + rhs)
244 }
245}
246
247impl std::ops::Sub for LandArea {
248 type Output = Self;
249
250 fn sub(self, rhs: Self) -> Self::Output {
251 let Self(lhs) = self;
252 let Self(rhs) = rhs;
253 #[expect(
254 clippy::arithmetic_side_effects,
255 reason = "the same underflow behaviour as the underlying integer subtraction, which is what a caller differencing areas expects"
256 )]
257 Self(lhs - rhs)
258 }
259}
260
261#[cfg(feature = "chumsky")]
269#[must_use]
270pub fn land_area_parser<'src>()
271-> impl Parser<'src, &'src str, LandArea, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
272 u32_parser()
273 .then_ignore(whitespace().or_not())
274 .then_ignore(just("m²"))
275 .map(LandArea)
276}
277
278#[derive(
282 Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
283)]
284pub struct GridCoordinates {
285 x: u16,
290 y: u16,
295}
296
297impl GridCoordinates {
298 #[must_use]
300 pub const fn new(x: u16, y: u16) -> Self {
301 Self { x, y }
302 }
303
304 #[must_use]
306 pub const fn x(&self) -> u16 {
307 self.x
308 }
309
310 #[must_use]
312 pub const fn y(&self) -> u16 {
313 self.y
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct GridCoordinateOffset {
320 x: i32,
322 y: i32,
324}
325
326impl GridCoordinateOffset {
327 #[must_use]
329 pub const fn new(x: i32, y: i32) -> Self {
330 Self { x, y }
331 }
332
333 #[must_use]
335 pub const fn x(&self) -> i32 {
336 self.x
337 }
338
339 #[must_use]
341 pub const fn y(&self) -> i32 {
342 self.y
343 }
344}
345
346impl std::ops::Add<GridCoordinateOffset> for GridCoordinates {
347 type Output = Self;
348
349 fn add(self, rhs: GridCoordinateOffset) -> Self::Output {
350 Self::new(
351 (<u16 as Into<i32>>::into(self.x).saturating_add(rhs.x))
352 .try_into()
353 .unwrap_or(if rhs.x > 0 { u16::MAX } else { u16::MIN }),
354 (<u16 as Into<i32>>::into(self.y).saturating_add(rhs.y))
355 .try_into()
356 .unwrap_or(if rhs.y > 0 { u16::MAX } else { u16::MIN }),
357 )
358 }
359}
360
361impl std::ops::Sub<Self> for GridCoordinates {
362 type Output = GridCoordinateOffset;
363
364 fn sub(self, rhs: Self) -> Self::Output {
365 GridCoordinateOffset::new(
366 <u16 as Into<i32>>::into(self.x).saturating_sub(<u16 as Into<i32>>::into(rhs.x)),
367 <u16 as Into<i32>>::into(self.y).saturating_sub(<u16 as Into<i32>>::into(rhs.y)),
368 )
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct GridRectangle {
376 lower_left_corner: GridCoordinates,
378 upper_right_corner: GridCoordinates,
380}
381
382impl GridRectangle {
383 #[must_use]
385 pub fn new(corner1: GridCoordinates, corner2: GridCoordinates) -> Self {
386 Self {
387 lower_left_corner: GridCoordinates::new(
388 corner1.x().min(corner2.x()),
389 corner1.y().min(corner2.y()),
390 ),
391 upper_right_corner: GridCoordinates::new(
392 corner1.x().max(corner2.x()),
393 corner1.y().max(corner2.y()),
394 ),
395 }
396 }
397
398 #[must_use]
402 #[expect(
403 clippy::arithmetic_side_effects,
404 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
405 )]
406 pub fn expanded_west(&self, by: u16) -> Self {
407 Self::new(
408 self.lower_left_corner.to_owned() + GridCoordinateOffset::new(-i32::from(by), 0),
409 self.upper_right_corner.to_owned(),
410 )
411 }
412
413 #[must_use]
417 #[expect(
418 clippy::arithmetic_side_effects,
419 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
420 )]
421 pub fn expanded_east(&self, by: u16) -> Self {
422 Self::new(
423 self.lower_left_corner.to_owned(),
424 self.upper_right_corner.to_owned() + GridCoordinateOffset::new(i32::from(by), 0),
425 )
426 }
427
428 #[must_use]
432 #[expect(
433 clippy::arithmetic_side_effects,
434 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
435 )]
436 pub fn expanded_south(&self, by: u16) -> Self {
437 Self::new(
438 self.lower_left_corner.to_owned() + GridCoordinateOffset::new(0, -i32::from(by)),
439 self.upper_right_corner.to_owned(),
440 )
441 }
442
443 #[must_use]
447 #[expect(
448 clippy::arithmetic_side_effects,
449 reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
450 )]
451 pub fn expanded_north(&self, by: u16) -> Self {
452 Self::new(
453 self.lower_left_corner.to_owned(),
454 self.upper_right_corner.to_owned() + GridCoordinateOffset::new(0, i32::from(by)),
455 )
456 }
457}
458
459pub trait GridRectangleLike {
462 #[must_use]
464 fn grid_rectangle(&self) -> GridRectangle;
465
466 #[must_use]
468 fn lower_left_corner(&self) -> GridCoordinates {
469 self.grid_rectangle().lower_left_corner().to_owned()
470 }
471
472 #[must_use]
474 fn lower_right_corner(&self) -> GridCoordinates {
475 GridCoordinates::new(
476 self.grid_rectangle().upper_right_corner().x(),
477 self.grid_rectangle().lower_left_corner().y(),
478 )
479 }
480
481 #[must_use]
483 fn upper_left_corner(&self) -> GridCoordinates {
484 GridCoordinates::new(
485 self.grid_rectangle().lower_left_corner().x(),
486 self.grid_rectangle().upper_right_corner().y(),
487 )
488 }
489
490 #[must_use]
492 fn upper_right_corner(&self) -> GridCoordinates {
493 self.grid_rectangle().upper_right_corner().to_owned()
494 }
495
496 #[must_use]
498 fn size_x(&self) -> u16 {
499 self.grid_rectangle().size_x()
500 }
501
502 #[must_use]
504 fn size_y(&self) -> u16 {
505 self.grid_rectangle().size_y()
506 }
507
508 #[must_use]
510 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
511 self.lower_left_corner().x()..=self.upper_right_corner().x()
512 }
513
514 #[must_use]
516 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
517 self.lower_left_corner().y()..=self.upper_right_corner().y()
518 }
519
520 #[must_use]
522 fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
523 self.lower_left_corner().x() <= grid_coordinates.x()
524 && grid_coordinates.x() <= self.upper_right_corner().x()
525 && self.lower_left_corner().y() <= grid_coordinates.y()
526 && grid_coordinates.y() <= self.upper_right_corner().y()
527 }
528
529 #[must_use]
532 fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
533 where
534 O: GridRectangleLike,
535 {
536 let self_x_range: ranges::GenericRange<u16> = self.x_range().into();
537 let self_y_range: ranges::GenericRange<u16> = self.y_range().into();
538 let other_x_range: ranges::GenericRange<u16> = other.x_range().into();
539 let other_y_range: ranges::GenericRange<u16> = other.y_range().into();
540 let x_intersection = self_x_range.intersect(other_x_range);
541 let y_intersection = self_y_range.intersect(other_y_range);
542 match (x_intersection, y_intersection) {
543 (
544 ranges::OperationResult::Single(x_range),
545 ranges::OperationResult::Single(y_range),
546 ) => {
547 use std::ops::Bound;
548 use std::ops::RangeBounds as _;
549 match (
550 x_range.start_bound(),
551 x_range.end_bound(),
552 y_range.start_bound(),
553 y_range.end_bound(),
554 ) {
555 (
556 Bound::Included(start_x),
557 Bound::Included(end_x),
558 Bound::Included(start_y),
559 Bound::Included(end_y),
560 ) => Some(GridRectangle::new(
561 GridCoordinates::new(*start_x, *start_y),
562 GridCoordinates::new(*end_x, *end_y),
563 )),
564 _ => None,
565 }
566 }
567 _ => None,
568 }
569 }
570
571 #[must_use]
582 fn pps_hud_config(&self) -> String {
583 let lower_left_corner_x = 256f32 * f32::from(self.lower_left_corner().x());
584 let lower_left_corner_y = 256f32 * f32::from(self.lower_left_corner().y());
585 format!(
590 "<{lower_left_corner_x},{lower_left_corner_y},0>/{}/{}/1",
591 f32::from(self.size_x()),
592 f32::from(self.size_y())
593 )
594 }
595}
596
597impl GridRectangleLike for GridRectangle {
598 fn grid_rectangle(&self) -> GridRectangle {
599 self.to_owned()
600 }
601
602 fn lower_left_corner(&self) -> GridCoordinates {
603 self.lower_left_corner.to_owned()
604 }
605
606 fn upper_right_corner(&self) -> GridCoordinates {
607 self.upper_right_corner.to_owned()
608 }
609
610 fn size_x(&self) -> u16 {
611 self.upper_right_corner
612 .x()
613 .saturating_sub(self.lower_left_corner().x())
614 .saturating_add(1)
615 }
616
617 fn size_y(&self) -> u16 {
618 self.upper_right_corner
619 .y()
620 .saturating_sub(self.lower_left_corner().y())
621 .saturating_add(1)
622 }
623
624 fn x_range(&self) -> std::ops::RangeInclusive<u16> {
625 self.lower_left_corner.x()..=self.upper_right_corner.x()
626 }
627
628 fn y_range(&self) -> std::ops::RangeInclusive<u16> {
629 self.lower_left_corner.y()..=self.upper_right_corner.y()
630 }
631}
632
633impl GridRectangleLike for MapTileDescriptor {
634 fn grid_rectangle(&self) -> GridRectangle {
635 GridRectangle::new(
636 self.lower_left_corner,
637 GridCoordinates::new(
638 self.lower_left_corner
639 .x()
640 .saturating_add(self.zoom_level.tile_size())
641 .saturating_sub(1),
642 self.lower_left_corner
643 .y()
644 .saturating_add(self.zoom_level.tile_size())
645 .saturating_sub(1),
646 ),
647 )
648 }
649}
650
651pub trait GridCoordinatesExt {
653 fn bounding_rectangle(&self) -> Option<GridRectangle>;
659}
660
661impl GridCoordinatesExt for Vec<GridCoordinates> {
662 fn bounding_rectangle(&self) -> Option<GridRectangle> {
663 if self.is_empty() {
664 return None;
665 }
666 let (xs, ys): (Vec<u16>, Vec<u16>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
667 #[expect(
669 clippy::unwrap_used,
670 reason = "we checked above that the container is non-empty"
671 )]
672 let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
673 #[expect(
674 clippy::unwrap_used,
675 reason = "we checked above that the container is non-empty"
676 )]
677 let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
678 Some(GridRectangle {
679 lower_left_corner: GridCoordinates::new(*min_x, *min_y),
680 upper_right_corner: GridCoordinates::new(*max_x, *max_y),
681 })
682 }
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
693pub struct RegionCoordinates {
694 x: f32,
697 y: f32,
700 z: f32,
704}
705
706#[cfg(feature = "chumsky")]
714#[must_use]
715pub fn region_coordinates_parser<'src>()
716-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
717{
718 just('{')
719 .ignore_then(whitespace().or_not())
720 .ignore_then(f32_parser())
721 .then_ignore(just(','))
722 .then_ignore(whitespace().or_not())
723 .then(f32_parser())
724 .then_ignore(just(','))
725 .then_ignore(whitespace().or_not())
726 .then(f32_parser())
727 .then_ignore(whitespace().or_not())
728 .then_ignore(just('}'))
729 .map(|((x, y), z)| RegionCoordinates::new(x, y, z))
730}
731
732impl RegionCoordinates {
733 #[must_use]
735 pub const fn new(x: f32, y: f32, z: f32) -> Self {
736 Self { x, y, z }
737 }
738
739 #[must_use]
741 pub const fn x(&self) -> f32 {
742 self.x
743 }
744
745 #[must_use]
747 pub const fn y(&self) -> f32 {
748 self.y
749 }
750
751 #[must_use]
753 pub const fn z(&self) -> f32 {
754 self.z
755 }
756
757 #[must_use]
759 pub fn in_bounds(&self) -> bool {
760 self.x >= 0f32
761 && self.x < 256f32
762 && self.y >= 0f32
763 && self.y < 256f32
764 && self.z >= 0f32
765 && self.z < 4096f32
766 }
767}
768
769impl From<crate::lsl::Vector> for RegionCoordinates {
770 fn from(value: crate::lsl::Vector) -> Self {
771 Self {
772 x: value.x,
773 y: value.y,
774 z: value.z,
775 }
776 }
777}
778
779#[nutype::nutype(
781 sanitize(trim),
782 validate(len_char_min = 2, len_char_max = 35),
783 derive(
784 Debug,
785 Clone,
786 Display,
787 Hash,
788 PartialEq,
789 Eq,
790 PartialOrd,
791 Ord,
792 Serialize,
793 Deserialize,
794 AsRef
795 )
796)]
797pub struct RegionName(String);
798
799#[cfg(feature = "chumsky")]
805#[must_use]
806pub fn url_region_name_parser<'src>()
807-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
808 url_text_component_parser().try_map(|region_name, span| {
809 RegionName::try_new(®ion_name).map_err(|err| {
810 chumsky::error::Rich::custom(
811 span,
812 format!("failed to parse url-encoded region name ({region_name}): {err:?}"),
813 )
814 })
815 })
816}
817
818#[cfg(feature = "chumsky")]
824#[must_use]
825pub fn region_name_parser<'src>()
826-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
827 any()
828 .filter(|c: &char| {
829 c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
830 })
831 .repeated()
832 .at_least(2)
833 .collect::<String>()
834 .try_map(|region_name, span| {
835 RegionName::try_new(®ion_name).map_err(|err| {
836 chumsky::error::Rich::custom(
837 span,
838 format!("failed to parse region name ({region_name}): {err:?}"),
839 )
840 })
841 })
842}
843
844#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
848pub struct Location {
849 pub region_name: RegionName,
851 pub x: u8,
853 pub y: u8,
855 pub z: u16,
857}
858
859#[cfg(feature = "chumsky")]
865#[must_use]
866pub fn location_parser<'src>()
867-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
868 region_name_parser()
869 .then_ignore(just('/'))
870 .then(u8_parser())
871 .then_ignore(just('/'))
872 .then(u8_parser())
873 .then_ignore(just('/'))
874 .then(u16_parser())
875 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
876}
877
878#[cfg(feature = "chumsky")]
885#[must_use]
886pub fn url_location_parser<'src>()
887-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
888 url_region_name_parser()
889 .then_ignore(just('/'))
890 .then(u8_parser())
891 .then_ignore(just('/'))
892 .then(u8_parser())
893 .then_ignore(just('/'))
894 .then(u16_parser())
895 .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
896}
897
898#[cfg(feature = "chumsky")]
905#[must_use]
906pub fn url_encoded_location_parser<'src>()
907-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
908 url_text_component_parser().try_map(|s, span| {
909 location_parser().parse(&s).into_result().map_err(|err| {
910 chumsky::error::Rich::custom(
911 span,
912 format!("Parsing {s} as location failed with: {err:#?}"),
913 )
914 })
915 })
916}
917
918#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
920pub enum LocationParseError {
921 #[error(
923 "unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
924 )]
925 UnexpectedComponentCount(String, usize),
926 #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
928 UnexpectedScheme(String, String),
929 #[error(
931 "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
932 )]
933 UnexpectedNonEmptySecondComponent(String, String),
934 #[error(
936 "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
937 )]
938 UnexpectedHost(String, String),
939 #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
941 UnexpectedPath(String, String),
942 #[error("error parsing the region name {0}: {1}")]
944 RegionName(String, RegionNameError),
945 #[error("error parsing the X coordinate {0}: {1}")]
947 X(String, std::num::ParseIntError),
948 #[error("error parsing the Y coordinate {0}: {1}")]
950 Y(String, std::num::ParseIntError),
951 #[error("error parsing the Z coordinate {0}: {1}")]
953 Z(String, std::num::ParseIntError),
954}
955
956impl std::str::FromStr for Location {
957 type Err = LocationParseError;
958
959 fn from_str(s: &str) -> Result<Self, Self::Err> {
960 let usb_location = s
962 .split_once(',')
963 .map_or(s, |(usb_location, _usb_comment)| usb_location);
964 let parts = usb_location.split('/').collect::<Vec<_>>();
965 if let [region_name, x, y, z] = parts.as_slice() {
966 let region_name = RegionName::try_new(region_name.replace("%20", " "))
967 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
968 let x = x
969 .parse()
970 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
971 let y = y
972 .parse()
973 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
974 let z = z
975 .parse()
976 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
977 return Ok(Self {
978 region_name,
979 x,
980 y,
981 z,
982 });
983 }
984 if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
985 if *scheme != "http:" && *scheme != "https:" {
986 return Err(LocationParseError::UnexpectedScheme(
987 s.to_owned(),
988 scheme.to_string(),
989 ));
990 }
991 if !second_component.is_empty() {
992 return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
993 s.to_owned(),
994 second_component.to_string(),
995 ));
996 }
997 if *host != "maps.secondlife.com" && *host != "slurl.com" {
998 return Err(LocationParseError::UnexpectedHost(
999 s.to_owned(),
1000 host.to_string(),
1001 ));
1002 }
1003 if *path != "secondlife" {
1004 return Err(LocationParseError::UnexpectedPath(
1005 s.to_owned(),
1006 path.to_string(),
1007 ));
1008 }
1009 let region_name = RegionName::try_new(region_name.replace("%20", " "))
1010 .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
1011 let x = x
1012 .parse()
1013 .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
1014 let y = y
1015 .parse()
1016 .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
1017 let z = z
1018 .parse()
1019 .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
1020 return Ok(Self {
1021 region_name,
1022 x,
1023 y,
1024 z,
1025 });
1026 }
1027 Err(LocationParseError::UnexpectedComponentCount(
1028 s.to_owned(),
1029 parts.len(),
1030 ))
1031 }
1032}
1033
1034impl Location {
1035 #[must_use]
1037 pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
1038 Self {
1039 region_name,
1040 x,
1041 y,
1042 z,
1043 }
1044 }
1045
1046 #[must_use]
1048 pub const fn region_name(&self) -> &RegionName {
1049 &self.region_name
1050 }
1051
1052 #[must_use]
1054 pub const fn x(&self) -> u8 {
1055 self.x
1056 }
1057
1058 #[must_use]
1060 pub const fn y(&self) -> u8 {
1061 self.y
1062 }
1063
1064 #[must_use]
1066 pub const fn z(&self) -> u16 {
1067 self.z
1068 }
1069
1070 #[must_use]
1072 pub fn as_maps_url(&self) -> String {
1073 format!(
1074 "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
1075 self.region_name, self.x, self.y, self.z
1076 )
1077 }
1078}
1079
1080#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1085pub struct UnconstrainedLocation {
1086 pub region_name: RegionName,
1088 pub x: i16,
1090 pub y: i16,
1092 pub z: i32,
1094}
1095
1096impl UnconstrainedLocation {
1097 #[must_use]
1099 pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
1100 Self {
1101 region_name,
1102 x,
1103 y,
1104 z,
1105 }
1106 }
1107
1108 #[must_use]
1110 pub const fn region_name(&self) -> &RegionName {
1111 &self.region_name
1112 }
1113
1114 #[must_use]
1116 pub const fn x(&self) -> i16 {
1117 self.x
1118 }
1119
1120 #[must_use]
1122 pub const fn y(&self) -> i16 {
1123 self.y
1124 }
1125
1126 #[must_use]
1128 pub const fn z(&self) -> i32 {
1129 self.z
1130 }
1131}
1132
1133#[cfg(feature = "chumsky")]
1139#[must_use]
1140pub fn unconstrained_location_parser<'src>() -> impl Parser<
1141 'src,
1142 &'src str,
1143 UnconstrainedLocation,
1144 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1145> {
1146 region_name_parser()
1147 .then_ignore(just('/'))
1148 .then(i16_parser())
1149 .then_ignore(just('/'))
1150 .then(i16_parser())
1151 .then_ignore(just('/'))
1152 .then(i32_parser())
1153 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1154}
1155
1156#[cfg(feature = "chumsky")]
1163#[must_use]
1164pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1165 'src,
1166 &'src str,
1167 UnconstrainedLocation,
1168 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1169> {
1170 url_region_name_parser()
1171 .then_ignore(just('/'))
1172 .then(i16_parser())
1173 .then_ignore(just('/'))
1174 .then(i16_parser())
1175 .then_ignore(just('/'))
1176 .then(i32_parser())
1177 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1178}
1179
1180#[cfg(feature = "chumsky")]
1187#[must_use]
1188pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1189 'src,
1190 &'src str,
1191 UnconstrainedLocation,
1192 chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1193> {
1194 url_region_name_parser()
1195 .then_ignore(just('/'))
1196 .then(i16_parser())
1197 .then_ignore(just('/'))
1198 .then(i16_parser())
1199 .then_ignore(just('/'))
1200 .then(i32_parser())
1201 .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1202}
1203
1204impl TryFrom<UnconstrainedLocation> for Location {
1205 type Error = std::num::TryFromIntError;
1206
1207 fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1208 Ok(Self::new(
1209 value.region_name,
1210 value.x.try_into()?,
1211 value.y.try_into()?,
1212 value.z.try_into()?,
1213 ))
1214 }
1215}
1216
1217impl From<Location> for UnconstrainedLocation {
1218 fn from(value: Location) -> Self {
1219 Self {
1220 region_name: value.region_name,
1221 x: value.x.into(),
1222 y: value.y.into(),
1223 z: value.z.into(),
1224 }
1225 }
1226}
1227
1228#[nutype::nutype(
1230 validate(greater_or_equal = 1, less_or_equal = 8),
1231 derive(
1232 Debug,
1233 Clone,
1234 Copy,
1235 Display,
1236 FromStr,
1237 Hash,
1238 PartialEq,
1239 Eq,
1240 PartialOrd,
1241 Ord,
1242 Serialize,
1243 Deserialize
1244 )
1245)]
1246pub struct ZoomLevel(u8);
1247
1248#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1251pub enum ZoomFitError {
1252 #[error("region size in x direction can not be zero")]
1254 RegionSizeXZero,
1255
1256 #[error("region size in y direction can not be zero")]
1258 RegionSizeYZero,
1259
1260 #[error("output image size in x direction can not be zero")]
1262 OutputSizeXZero,
1263
1264 #[error("output image size in y direction can not be zero")]
1266 OutputSizeYZero,
1267
1268 #[error("error converting a logarithm value into a u8")]
1270 LogarithmConversionError(#[from] std::num::TryFromIntError),
1271
1272 #[error("error creating zoom level from calculated value")]
1275 ZoomLevelError(#[from] ZoomLevelError),
1276}
1277
1278impl ZoomLevel {
1279 #[must_use]
1284 pub fn tile_size(&self) -> u16 {
1285 let exponent: u32 = self.into_inner().into();
1286 let exponent = exponent.saturating_sub(1);
1287 2u16.pow(exponent)
1288 }
1289
1290 #[expect(
1295 clippy::arithmetic_side_effects,
1296 reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1297 )]
1298 #[must_use]
1299 pub fn tile_size_in_pixels(&self) -> u32 {
1300 let tile_size: u32 = self.tile_size().into();
1301 let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1302 tile_size * region_size_in_map_tile_in_pixels
1303 }
1304
1305 #[must_use]
1312 pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1313 let tile_size = self.tile_size();
1314 #[expect(
1315 clippy::arithmetic_side_effects,
1316 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)"
1317 )]
1318 GridCoordinates {
1319 x: x.saturating_sub(x % tile_size),
1320 y: y.saturating_sub(y % tile_size),
1321 }
1322 }
1323
1324 #[must_use]
1329 pub fn pixels_per_region(&self) -> u16 {
1330 let exponent: u32 = self.into_inner().into();
1331 let exponent = exponent.saturating_sub(1);
1332 let exponent = 8u32.saturating_sub(exponent);
1333 2u16.pow(exponent)
1334 }
1335
1336 #[must_use]
1338 pub fn pixels_per_meter(&self) -> f32 {
1339 f32::from(self.pixels_per_region()) / 256f32
1340 }
1341
1342 pub fn max_zoom_level_to_fit_regions_into_output_image(
1352 region_x: u16,
1353 region_y: u16,
1354 output_x: u32,
1355 output_y: u32,
1356 ) -> Result<Self, ZoomFitError> {
1357 if region_x == 0 {
1358 return Err(ZoomFitError::RegionSizeXZero);
1359 }
1360 if region_y == 0 {
1361 return Err(ZoomFitError::RegionSizeYZero);
1362 }
1363 if output_x == 0 {
1364 return Err(ZoomFitError::OutputSizeXZero);
1365 }
1366 if output_y == 0 {
1367 return Err(ZoomFitError::OutputSizeYZero);
1368 }
1369 let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x.into());
1370 let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y.into());
1371 let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1372 8,
1373 output_pixels_per_region_x
1374 .ilog2()
1375 .try_into()
1376 .map_err(ZoomFitError::LogarithmConversionError)?,
1377 ));
1378 let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1379 8,
1380 output_pixels_per_region_y
1381 .ilog2()
1382 .try_into()
1383 .map_err(ZoomFitError::LogarithmConversionError)?,
1384 ));
1385 Ok(Self::try_new(std::cmp::max(
1386 max_zoom_level_x,
1387 max_zoom_level_y,
1388 ))?)
1389 }
1390}
1391
1392#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1394#[expect(
1395 clippy::module_name_repetitions,
1396 reason = "the type is used outside this module"
1397)]
1398pub struct MapTileDescriptor {
1399 zoom_level: ZoomLevel,
1401 lower_left_corner: GridCoordinates,
1403}
1404
1405impl MapTileDescriptor {
1406 #[must_use]
1411 pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1412 let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1413 Self {
1414 zoom_level,
1415 lower_left_corner,
1416 }
1417 }
1418
1419 #[must_use]
1421 pub const fn zoom_level(&self) -> &ZoomLevel {
1422 &self.zoom_level
1423 }
1424
1425 #[must_use]
1427 pub const fn lower_left_corner(&self) -> &GridCoordinates {
1428 &self.lower_left_corner
1429 }
1430
1431 #[must_use]
1433 pub fn tile_size(&self) -> u16 {
1434 self.zoom_level.tile_size()
1435 }
1436
1437 #[must_use]
1439 pub fn tile_size_in_pixels(&self) -> u32 {
1440 self.zoom_level.tile_size_in_pixels()
1441 }
1442
1443 #[must_use]
1445 pub fn grid_rectangle(&self) -> GridRectangle {
1446 GridRectangle::new(
1447 self.lower_left_corner,
1448 GridCoordinates::new(
1449 self.lower_left_corner
1450 .x()
1451 .saturating_add(self.zoom_level.tile_size())
1452 .saturating_sub(1),
1453 self.lower_left_corner
1454 .y()
1455 .saturating_add(self.zoom_level.tile_size())
1456 .saturating_sub(1),
1457 ),
1458 )
1459 }
1460}
1461
1462#[derive(Debug, Clone)]
1464pub struct USBWaypoint {
1465 location: Location,
1467 comment: Option<String>,
1469}
1470
1471impl USBWaypoint {
1472 #[must_use]
1474 pub const fn new(location: Location, comment: Option<String>) -> Self {
1475 Self { location, comment }
1476 }
1477
1478 #[must_use]
1480 pub const fn location(&self) -> &Location {
1481 &self.location
1482 }
1483
1484 #[must_use]
1486 pub fn region_coordinates(&self) -> RegionCoordinates {
1487 RegionCoordinates::new(
1488 f32::from(self.location.x()),
1489 f32::from(self.location.y()),
1490 f32::from(self.location.z()),
1491 )
1492 }
1493
1494 #[must_use]
1496 pub const fn comment(&self) -> Option<&String> {
1497 self.comment.as_ref()
1498 }
1499}
1500
1501impl std::fmt::Display for USBWaypoint {
1502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1503 write!(f, "{}", self.location.as_maps_url())?;
1504 if let Some(comment) = &self.comment {
1505 write!(f, ",{comment}")?;
1506 }
1507 Ok(())
1508 }
1509}
1510
1511impl std::str::FromStr for USBWaypoint {
1512 type Err = LocationParseError;
1513
1514 fn from_str(s: &str) -> Result<Self, Self::Err> {
1515 if let Some((location, comment)) = s.split_once(',') {
1516 Ok(Self {
1517 location: location.parse()?,
1518 comment: Some(comment.to_owned()),
1519 })
1520 } else {
1521 Ok(Self {
1522 location: s.parse()?,
1523 comment: None,
1524 })
1525 }
1526 }
1527}
1528
1529#[derive(Debug, Clone)]
1531pub struct USBNotecard {
1532 waypoints: Vec<USBWaypoint>,
1534}
1535
1536#[derive(Debug, thiserror::Error, strum::EnumIs)]
1538pub enum USBNotecardLoadError {
1539 #[error("I/O error opening or reading the file: {0}")]
1541 Io(#[from] std::io::Error),
1542 #[error("parse error deserializing the USB notecard lines: {0}")]
1544 LocationParseError(#[from] LocationParseError),
1545}
1546
1547impl USBNotecard {
1548 #[must_use]
1550 pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1551 Self { waypoints }
1552 }
1553
1554 #[must_use]
1556 pub fn waypoints(&self) -> &[USBWaypoint] {
1557 &self.waypoints
1558 }
1559
1560 pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1567 let contents = std::fs::read_to_string(filename)?;
1568 Ok(contents.parse()?)
1569 }
1570}
1571
1572impl std::fmt::Display for USBNotecard {
1573 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1574 for waypoint in &self.waypoints {
1575 writeln!(f, "{waypoint}")?;
1576 }
1577 Ok(())
1578 }
1579}
1580
1581impl std::str::FromStr for USBNotecard {
1582 type Err = LocationParseError;
1583
1584 fn from_str(s: &str) -> Result<Self, Self::Err> {
1585 s.lines()
1586 .map(|line| line.parse::<USBWaypoint>())
1587 .collect::<Result<Vec<_>, _>>()
1588 .map(|waypoints| Self { waypoints })
1589 }
1590}
1591
1592#[cfg(test)]
1593mod test {
1594 use super::*;
1595 use pretty_assertions::assert_eq;
1596
1597 #[test]
1598 fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1599 assert_eq!(
1600 "Beach%20Valley/110/67/24".parse::<Location>(),
1601 Ok(Location {
1602 region_name: RegionName::try_new("Beach Valley")?,
1603 x: 110,
1604 y: 67,
1605 z: 24
1606 }),
1607 );
1608 Ok(())
1609 }
1610
1611 #[test]
1612 fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1613 assert_eq!(
1614 "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1615 Ok(Location {
1616 region_name: RegionName::try_new("Beach Valley")?,
1617 x: 110,
1618 y: 67,
1619 z: 24
1620 }),
1621 );
1622 Ok(())
1623 }
1624
1625 #[test]
1626 fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1627 assert_eq!(
1628 "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1629 Ok(Location {
1630 region_name: RegionName::try_new("Beach Valley")?,
1631 x: 110,
1632 y: 67,
1633 z: 24
1634 }),
1635 );
1636 Ok(())
1637 }
1638
1639 #[test]
1640 fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
1641 assert_eq!(
1642 "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
1643 Ok(Location {
1644 region_name: RegionName::try_new("Beach Valley")?,
1645 x: 110,
1646 y: 67,
1647 z: 24
1648 }),
1649 );
1650 Ok(())
1651 }
1652
1653 #[test]
1654 fn test_grid_rectangle_intersection_upper_right_corner()
1655 -> Result<(), Box<dyn std::error::Error>> {
1656 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1657 let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
1658 assert_eq!(
1659 rect1.intersect(&rect2),
1660 Some(GridRectangle::new(
1661 GridCoordinates::new(15, 15),
1662 GridCoordinates::new(20, 20),
1663 ))
1664 );
1665 Ok(())
1666 }
1667
1668 #[test]
1669 fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
1670 {
1671 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1672 let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
1673 assert_eq!(
1674 rect1.intersect(&rect2),
1675 Some(GridRectangle::new(
1676 GridCoordinates::new(10, 15),
1677 GridCoordinates::new(15, 20),
1678 ))
1679 );
1680 Ok(())
1681 }
1682
1683 #[test]
1684 fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
1685 {
1686 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1687 let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
1688 assert_eq!(
1689 rect1.intersect(&rect2),
1690 Some(GridRectangle::new(
1691 GridCoordinates::new(10, 10),
1692 GridCoordinates::new(15, 15),
1693 ))
1694 );
1695 Ok(())
1696 }
1697
1698 #[test]
1699 fn test_grid_rectangle_intersection_lower_right_corner()
1700 -> Result<(), Box<dyn std::error::Error>> {
1701 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1702 let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
1703 assert_eq!(
1704 rect1.intersect(&rect2),
1705 Some(GridRectangle::new(
1706 GridCoordinates::new(15, 10),
1707 GridCoordinates::new(20, 15),
1708 ))
1709 );
1710 Ok(())
1711 }
1712
1713 #[test]
1714 fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
1715 let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1716 let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
1717 assert_eq!(rect1.intersect(&rect2), None);
1718 Ok(())
1719 }
1720
1721 #[test]
1722 fn test_grid_rectangle_expanded_west() {
1723 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1724 assert_eq!(rect.expanded_west(0), rect);
1725 assert_eq!(
1726 rect.expanded_west(3),
1727 GridRectangle::new(GridCoordinates::new(7, 10), GridCoordinates::new(20, 20)),
1728 );
1729 let near_edge =
1730 GridRectangle::new(GridCoordinates::new(2, 10), GridCoordinates::new(20, 20));
1731 assert_eq!(
1732 near_edge.expanded_west(5),
1733 GridRectangle::new(GridCoordinates::new(0, 10), GridCoordinates::new(20, 20)),
1734 );
1735 }
1736
1737 #[test]
1738 fn test_grid_rectangle_expanded_east() {
1739 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1740 assert_eq!(rect.expanded_east(0), rect);
1741 assert_eq!(
1742 rect.expanded_east(3),
1743 GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(23, 20)),
1744 );
1745 let near_edge = GridRectangle::new(
1746 GridCoordinates::new(10, 10),
1747 GridCoordinates::new(u16::MAX - 2, 20),
1748 );
1749 assert_eq!(
1750 near_edge.expanded_east(5),
1751 GridRectangle::new(
1752 GridCoordinates::new(10, 10),
1753 GridCoordinates::new(u16::MAX, 20),
1754 ),
1755 );
1756 }
1757
1758 #[test]
1759 fn test_grid_rectangle_expanded_south() {
1760 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1761 assert_eq!(rect.expanded_south(0), rect);
1762 assert_eq!(
1763 rect.expanded_south(3),
1764 GridRectangle::new(GridCoordinates::new(10, 7), GridCoordinates::new(20, 20)),
1765 );
1766 let near_edge =
1767 GridRectangle::new(GridCoordinates::new(10, 2), GridCoordinates::new(20, 20));
1768 assert_eq!(
1769 near_edge.expanded_south(5),
1770 GridRectangle::new(GridCoordinates::new(10, 0), GridCoordinates::new(20, 20)),
1771 );
1772 }
1773
1774 #[test]
1775 fn test_grid_rectangle_expanded_north() {
1776 let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1777 assert_eq!(rect.expanded_north(0), rect);
1778 assert_eq!(
1779 rect.expanded_north(3),
1780 GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 23)),
1781 );
1782 let near_edge = GridRectangle::new(
1783 GridCoordinates::new(10, 10),
1784 GridCoordinates::new(20, u16::MAX - 2),
1785 );
1786 assert_eq!(
1787 near_edge.expanded_north(5),
1788 GridRectangle::new(
1789 GridCoordinates::new(10, 10),
1790 GridCoordinates::new(20, u16::MAX),
1791 ),
1792 );
1793 }
1794
1795 #[cfg(feature = "chumsky")]
1796 #[test]
1797 fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1798 let region_name = "Viterbo";
1799 assert_eq!(
1800 url_region_name_parser().parse(region_name).into_result(),
1801 Ok(RegionName::try_new(region_name)?)
1802 );
1803 Ok(())
1804 }
1805
1806 #[cfg(feature = "chumsky")]
1807 #[test]
1808 fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1809 let region_name = "Da Boom";
1810 let input = region_name.replace(' ', "%20");
1811 assert_eq!(
1812 url_region_name_parser().parse(&input).into_result(),
1813 Ok(RegionName::try_new(region_name)?)
1814 );
1815 Ok(())
1816 }
1817
1818 #[cfg(feature = "chumsky")]
1819 #[test]
1820 fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1821 let region_name = "Da Boom";
1822 assert_eq!(
1823 region_name_parser().parse(region_name).into_result(),
1824 Ok(RegionName::try_new(region_name)?)
1825 );
1826 Ok(())
1827 }
1828
1829 #[cfg(feature = "chumsky")]
1830 #[test]
1831 fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1832 let region_name = "Viterbo";
1833 let input = format!("{region_name}/1/2/300");
1834 assert_eq!(
1835 url_location_parser().parse(&input).into_result(),
1836 Ok(Location {
1837 region_name: RegionName::try_new(region_name)?,
1838 x: 1,
1839 y: 2,
1840 z: 300
1841 })
1842 );
1843 Ok(())
1844 }
1845
1846 #[cfg(feature = "chumsky")]
1847 #[test]
1848 fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1849 let region_name = "Da Boom";
1850 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1851 assert_eq!(
1852 url_location_parser().parse(&input).into_result(),
1853 Ok(Location {
1854 region_name: RegionName::try_new(region_name)?,
1855 x: 1,
1856 y: 2,
1857 z: 300
1858 })
1859 );
1860 Ok(())
1861 }
1862
1863 #[cfg(feature = "chumsky")]
1864 #[test]
1865 fn test_url_location_parser_url_whitespace_single_digit_after_space()
1866 -> Result<(), Box<dyn std::error::Error>> {
1867 let region_name = "Foo Bar 3";
1868 let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1869 assert_eq!(
1870 url_location_parser().parse(&input).into_result(),
1871 Ok(Location {
1872 region_name: RegionName::try_new(region_name)?,
1873 x: 1,
1874 y: 2,
1875 z: 300
1876 })
1877 );
1878 Ok(())
1879 }
1880
1881 #[cfg(feature = "chumsky")]
1882 #[test]
1883 fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
1884 assert_eq!(
1885 region_coordinates_parser()
1886 .parse("{ 63.0486, 45.2515, 1501.08 }")
1887 .into_result(),
1888 Ok(RegionCoordinates {
1889 x: 63.0486,
1890 y: 45.2515,
1891 z: 1501.08,
1892 })
1893 );
1894 Ok(())
1895 }
1896}