Skip to main content

sl_types/
map.rs

1//! Map-related data types
2
3#[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/// represents a Second Life distance in meters
16#[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/// parse a distance
170///
171/// "235.23 m"
172///
173/// # Errors
174///
175/// returns an error if the string could not be parsed
176#[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/// Grid coordinates for the position of a region on the map
187///
188/// the first region, Da Boom is located at 1000, 1000
189#[derive(
190    Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
191)]
192pub struct GridCoordinates {
193    /// the x coordinate of the region, this is basically the horizontal
194    /// position of the region on the map increasing from west to east
195    ///
196    /// common values are between roughly 395 and 1358
197    x: u16,
198    /// the y coordinate of the region, this is basically the vertical
199    /// position of the region on the map increasing from south to north
200    ///
201    /// common values are between roughly 479 and 1430
202    y: u16,
203}
204
205impl GridCoordinates {
206    /// Create a new `GridCoordinates`
207    #[must_use]
208    pub const fn new(x: u16, y: u16) -> Self {
209        Self { x, y }
210    }
211
212    /// The x coordinate of the region
213    #[must_use]
214    pub const fn x(&self) -> u16 {
215        self.x
216    }
217
218    /// The y coordinate of the region
219    #[must_use]
220    pub const fn y(&self) -> u16 {
221        self.y
222    }
223}
224
225/// an offset between two `GridCoordinates`
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct GridCoordinateOffset {
228    /// the offset in the x direction
229    x: i32,
230    /// the offset in the y direction
231    y: i32,
232}
233
234impl GridCoordinateOffset {
235    /// creates a new `GridCoordinateOffset`
236    #[must_use]
237    pub const fn new(x: i32, y: i32) -> Self {
238        Self { x, y }
239    }
240
241    /// the offset in the x direction
242    #[must_use]
243    pub const fn x(&self) -> i32 {
244        self.x
245    }
246
247    /// the offset in the y direction
248    #[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/// represents a rectangle of regions defined by the lower left (minimum coordinates)
281/// and upper right (maximum coordinates) corners in `GridCoordinates`
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct GridRectangle {
284    /// the lower left (minimum coordinates) corner of the rectangle
285    lower_left_corner: GridCoordinates,
286    /// the upper right (maximum coordinates) corner of the rectangle
287    upper_right_corner: GridCoordinates,
288}
289
290impl GridRectangle {
291    /// creates a new `GridRectangle` given any two corners
292    #[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
307/// represents a grid rectangle like type (usually one that contains a
308/// grid rectangle or one that contains a corner and is of a known size
309pub trait GridRectangleLike {
310    /// the `GridRectangle` represented by this map like image
311    #[must_use]
312    fn grid_rectangle(&self) -> GridRectangle;
313
314    /// returns the lower left corner of the rectangle
315    #[must_use]
316    fn lower_left_corner(&self) -> GridCoordinates {
317        self.grid_rectangle().lower_left_corner().to_owned()
318    }
319
320    /// returns the lower right corner of the rectangle
321    #[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    /// returns the upper left corner of the rectangle
330    #[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    /// returns the upper right corner of the rectangle
339    #[must_use]
340    fn upper_right_corner(&self) -> GridCoordinates {
341        self.grid_rectangle().upper_right_corner().to_owned()
342    }
343
344    /// the size of the map like image in regions in the x direction (width)
345    #[must_use]
346    fn size_x(&self) -> u16 {
347        self.grid_rectangle().size_x()
348    }
349
350    /// the size of the map like image in regions in the y direction (width)
351    #[must_use]
352    fn size_y(&self) -> u16 {
353        self.grid_rectangle().size_y()
354    }
355
356    /// returns a range for the region x coordinates of this rectangle
357    #[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    /// returns a range for the region y coordinates of this rectangle
363    #[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    /// checks if a given set of `GridCoordinates` is within this `GridRectangle`
369    #[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    /// returns a new `GridRectangle` which is the area where this `GridRectangle`
378    /// and another intersect each other or None if there is no intersection
379    #[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    /// returns a PPS HUD description string for this `GridRectangle`
420    ///
421    /// The PPS HUD is a map HUD commonly used in the SL sailing community
422    /// and usually you need to configure it by clicking on the HUD while
423    /// you are at the matching location in-world to calibrate the coordinates
424    /// on the map texture.
425    ///
426    /// This string needs to be put in the description of the PPS HUD
427    /// dot prim with "Edit linked objects" to avoid the need for manual
428    /// calibration.
429    #[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        // this is basically the lower left corner as an LSL vector of meters from the grid coordinate origin
434        // followed by the width and height of the map in regions
435        // and a 0/1 for the locked state of the HUD
436        // each of those is separated from the next by a slash character
437        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
499/// A trait to allow adding methods to `Vec<GridCoordinates>`
500pub trait GridCoordinatesExt {
501    /// returns the coordinates of the lower left corner and the coordinates of
502    /// the upper right corner of a rectangle of regions containing all the grid
503    /// coordinates in this container
504    ///
505    /// returns None if the container is empty
506    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        // unwrap is okay in these cases because we checked above that the container is non-empty
516        #[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/// Region coordinates for the position of something inside a region
534///
535/// Usually limited to 0..256 for x and y and 0..4096 for z (height)
536/// but values outside those ranges are possible for positions of objects
537/// in the process of crossing from one region to another or in similar
538/// situations where they belong to one simulator logically but are located
539/// outside of that simulator's region
540#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
541pub struct RegionCoordinates {
542    /// the x coordinate inside the region from the western edge (0) to the
543    /// eastern edge (256)
544    x: f32,
545    /// the y coordinate inside the region from the southern edge (0) to the
546    /// northern edge (256)
547    y: f32,
548    /// the z coordinate inside the region from the bottom (0) to the top (4096)
549    /// higher values are possible but for objects can not be rezzed above 4096m
550    /// and teleports are clamped to that as well
551    z: f32,
552}
553
554/// parse region coordinates
555///
556/// "{ 1.234, 2.345, 3.456 }"
557///
558/// # Errors
559///
560/// returns an error if the string could not be parsed
561#[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    /// Create a new `RegionCoordinates`
582    #[must_use]
583    pub const fn new(x: f32, y: f32, z: f32) -> Self {
584        Self { x, y, z }
585    }
586
587    /// The x coordinate inside the region
588    #[must_use]
589    pub const fn x(&self) -> f32 {
590        self.x
591    }
592
593    /// The y coordinate inside the region
594    #[must_use]
595    pub const fn y(&self) -> f32 {
596        self.y
597    }
598
599    /// The z coordinate inside the region
600    #[must_use]
601    pub const fn z(&self) -> f32 {
602        self.z
603    }
604
605    /// checks if the coordinates are within bounds
606    #[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/// The name of a region
628#[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/// parse an url encoded string into a RegionName
648///
649/// # Errors
650///
651/// returns an error if the string could not be parsed
652#[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/// parse a string into a RegionName
662///
663/// # Errors
664///
665/// returns an error if the string could not be parsed
666#[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/// A location inside Second Life the way it is usually represented in
683/// SLURLs or map URLs, based on a Region Name and integer coordinates
684/// inside the region
685#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
686pub struct Location {
687    /// the name of the region of the location
688    pub region_name: RegionName,
689    /// the x coordinate inside the region
690    pub x: u8,
691    /// the y coordinate inside the region
692    pub y: u8,
693    /// the z coordinate inside the region
694    pub z: u16,
695}
696
697/// parse a string into a Location where no component is url-encoded
698///
699/// # Errors
700///
701/// returns an error if the string could not be parsed
702#[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/// parse a string into a Location where the region name is url encoded
717/// but each component of the location is separated by an actual slash
718///
719/// # Errors
720///
721/// returns an error if the string could not be parsed
722#[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/// parse a string into a Location from a URL-encoded location (the slashes in
737/// particular)
738///
739/// # Errors
740///
741/// returns an error if the string could not be parsed
742#[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/// the possible errors that can occur when parsing a String to a `Location`
757#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
758pub enum LocationParseError {
759    /// unexpected number of /-separated components in the location URL
760    #[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    /// unexpected scheme in the location URL
765    #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
766    UnexpectedScheme(String, String),
767    /// unexpected non-empty second component in location URL
768    #[error(
769        "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
770    )]
771    UnexpectedNonEmptySecondComponent(String, String),
772    /// unexpected host in the location URL
773    #[error(
774        "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
775    )]
776    UnexpectedHost(String, String),
777    /// unexpected path in the location URL
778    #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
779    UnexpectedPath(String, String),
780    /// error parsing the region name
781    #[error("error parsing the region name {0}: {1}")]
782    RegionName(String, RegionNameError),
783    /// error parsing the X coordinate
784    #[error("error parsing the X coordinate {0}: {1}")]
785    X(String, std::num::ParseIntError),
786    /// error parsing the Y coordinate
787    #[error("error parsing the Y coordinate {0}: {1}")]
788    Y(String, std::num::ParseIntError),
789    /// error parsing the Z coordinate
790    #[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        // if the string is an USB-notecard line drop everything after the first comma
799        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    /// Creates a new `Location`
874    #[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    /// The region name of this `Location`
885    #[must_use]
886    pub const fn region_name(&self) -> &RegionName {
887        &self.region_name
888    }
889
890    /// The x coordinate of the `Location`
891    #[must_use]
892    pub const fn x(&self) -> u8 {
893        self.x
894    }
895
896    /// The y coordinate of the `Location`
897    #[must_use]
898    pub const fn y(&self) -> u8 {
899        self.y
900    }
901
902    /// The z coordinate of the `Location`
903    #[must_use]
904    pub const fn z(&self) -> u16 {
905        self.z
906    }
907
908    /// returns a maps.secondlife.com URL for the `Location`
909    #[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/// A location inside Second Life the way it is usually represented in
919/// SLURLs or map URLs, based on a Region Name and integer coordinates
920/// inside the region, this variant allows out of bounds coordinates
921/// (negative and 256 or above for x and y and negative for z)
922#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
923pub struct UnconstrainedLocation {
924    /// the name of the region of the location
925    pub region_name: RegionName,
926    /// the x coordinate inside the region
927    pub x: i16,
928    /// the y coordinate inside the region
929    pub y: i16,
930    /// the z coordinate inside the region
931    pub z: i32,
932}
933
934impl UnconstrainedLocation {
935    /// Creates a new `UnconstrainedLocation`
936    #[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    /// The region name of this `UnconstrainedLocation`
947    #[must_use]
948    pub const fn region_name(&self) -> &RegionName {
949        &self.region_name
950    }
951
952    /// The x coordinate of the `UnconstrainedLocation`
953    #[must_use]
954    pub const fn x(&self) -> i16 {
955        self.x
956    }
957
958    /// The y coordinate of the `UnconstrainedLocation`
959    #[must_use]
960    pub const fn y(&self) -> i16 {
961        self.y
962    }
963
964    /// The z coordinate of the `UnconstrainedLocation`
965    #[must_use]
966    pub const fn z(&self) -> i32 {
967        self.z
968    }
969}
970
971/// parse a string into an UnconstrainedLocation where nothing is urlencoded
972///
973/// # Errors
974///
975/// returns an error if the string could not be parsed
976#[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/// parse a string into an UnconstrainedLocation where the region is urlencoded
995/// but the components are separated by actual slashes
996///
997/// # Errors
998///
999/// returns an error if the string could not be parsed
1000#[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/// parse a string into an UnconstrainedLocation where the entire location is
1019/// urlencoded with urlencoded slashes
1020///
1021/// # Errors
1022///
1023/// returns an error if the string could not be parsed
1024#[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/// The map tile zoom level for the Second Life main map
1067#[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/// Errors that can occur when trying to find the correct zoom level to fit
1087/// regions into an output image of a given size
1088#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1089pub enum ZoomFitError {
1090    /// The region size in the x direction can not be zero
1091    #[error("region size in x direction can not be zero")]
1092    RegionSizeXZero,
1093
1094    /// The region size in the y direction can not be zero
1095    #[error("region size in y direction can not be zero")]
1096    RegionSizeYZero,
1097
1098    /// The output image size in the x direction can not be zero
1099    #[error("output image size in x direction can not be zero")]
1100    OutputSizeXZero,
1101
1102    /// The output image size in the y direction can not be zero
1103    #[error("output image size in y direction can not be zero")]
1104    OutputSizeYZero,
1105
1106    /// Error converting a logarithm value into a `u8` (should never happen)
1107    #[error("error converting a logarithm value into a u8")]
1108    LogarithmConversionError(#[from] std::num::TryFromIntError),
1109
1110    /// Error creating the zoom level from the calculated value
1111    /// (should never happen)
1112    #[error("error creating zoom level from calculated value")]
1113    ZoomLevelError(#[from] ZoomLevelError),
1114}
1115
1116impl ZoomLevel {
1117    /// returns the map tile size in number of regions at this zoom level
1118    ///
1119    /// This applies to both dimensions equally since both regions and map tiles
1120    /// are square
1121    #[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    /// returns the map tile size in pixels at this zoom level
1129    ///
1130    /// This applies to both dimensions equally since both regions and map tiles
1131    /// are square
1132    #[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    /// returns the lower left (lowest coordinate for each axis) coordinate of
1144    /// the map tile containing the given grid coordinates at this zoom level
1145    ///
1146    /// That is the coordinates used for the file name of the map tile at this
1147    /// zoom level that contains the region (or gap where a region could be)
1148    /// given by the grid coordinates
1149    #[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    /// returns the size of a region in pixels in a map tile of this zoom level
1163    ///
1164    /// The size applies to both dimensions equally since both regions and map tiles
1165    /// are square
1166    #[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    /// returns the number of pixels per meter at this zoom level
1175    #[must_use]
1176    pub fn pixels_per_meter(&self) -> f32 {
1177        f32::from(self.pixels_per_region()) / 256f32
1178    }
1179
1180    /// returns the zoom level that is the highest zoom level that makes sense
1181    /// to use if we want to fit a given area of regions into a given image size
1182    /// assuming we want to always have one map tile pixel on one output pixel
1183    ///
1184    /// # Errors
1185    ///
1186    /// returns an error if any of the parameters are zero or in the (theoretically
1187    /// impossible if the algorithm is correct) case that ZoomLevel::try_new()
1188    /// returns an error on the calculated value
1189    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/// describes a map tile
1231#[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    /// the zoom level of the map tile
1238    zoom_level: ZoomLevel,
1239    /// the lower left corner of the map tile
1240    lower_left_corner: GridCoordinates,
1241}
1242
1243impl MapTileDescriptor {
1244    /// create a new `MapTileDescriptor`
1245    ///
1246    /// this will automatically normalize the given `GridCoordinates` to the
1247    /// lower left corner of a map tile at that zoom level
1248    #[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    /// the `ZoomLevel` of the map tile
1258    #[must_use]
1259    pub const fn zoom_level(&self) -> &ZoomLevel {
1260        &self.zoom_level
1261    }
1262
1263    /// the `GridCoordinates` of the lower left corner of this map tile
1264    #[must_use]
1265    pub const fn lower_left_corner(&self) -> &GridCoordinates {
1266        &self.lower_left_corner
1267    }
1268
1269    /// the size of this map tile in regions
1270    #[must_use]
1271    pub fn tile_size(&self) -> u16 {
1272        self.zoom_level.tile_size()
1273    }
1274
1275    /// the size of this map tile in pixels
1276    #[must_use]
1277    pub fn tile_size_in_pixels(&self) -> u32 {
1278        self.zoom_level.tile_size_in_pixels()
1279    }
1280
1281    /// the grid rectangle covered by this map tile
1282    #[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/// A waypoint in the Universal Sailor Buddy (USB) notecard format
1301#[derive(Debug, Clone)]
1302pub struct USBWaypoint {
1303    /// the location of the waypoint
1304    location: Location,
1305    /// the comment for the waypoint if any
1306    comment: Option<String>,
1307}
1308
1309impl USBWaypoint {
1310    /// Create a new USB waypoint
1311    #[must_use]
1312    pub const fn new(location: Location, comment: Option<String>) -> Self {
1313        Self { location, comment }
1314    }
1315
1316    /// get the location of the waypoint
1317    #[must_use]
1318    pub const fn location(&self) -> &Location {
1319        &self.location
1320    }
1321
1322    /// get the region coordinates of the waypoint
1323    #[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    /// get the comment for the waypoint if any
1333    #[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/// An Universal Sailor Buddy (USB) notecard
1368#[derive(Debug, Clone)]
1369pub struct USBNotecard {
1370    /// the waypoints in the notecard
1371    waypoints: Vec<USBWaypoint>,
1372}
1373
1374/// Errors that can happen when an USB notecard is read from a file
1375#[derive(Debug, thiserror::Error, strum::EnumIs)]
1376pub enum USBNotecardLoadError {
1377    /// I/O errors opening or reading the file
1378    #[error("I/O error opening or reading the file: {0}")]
1379    Io(#[from] std::io::Error),
1380    /// Parse error deserializing the USB notecard lines
1381    #[error("parse error deserializing the USB notecard lines: {0}")]
1382    LocationParseError(#[from] LocationParseError),
1383}
1384
1385impl USBNotecard {
1386    /// Create a new USB notecard
1387    #[must_use]
1388    pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1389        Self { waypoints }
1390    }
1391
1392    /// get the waypoints in the notecard
1393    #[must_use]
1394    pub fn waypoints(&self) -> &[USBWaypoint] {
1395        &self.waypoints
1396    }
1397
1398    /// load an USB Notecard from a text file
1399    ///
1400    /// # Errors
1401    ///
1402    /// this returns an error if either reading the file or parsing the content
1403    /// as a `USBNotecard` fail
1404    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}