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    /// returns a new `GridRectangle` extended by `by` regions on the west (-x) side
307    ///
308    /// Saturates at the western edge of the grid (x = 0).
309    #[must_use]
310    #[expect(
311        clippy::arithmetic_side_effects,
312        reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
313    )]
314    pub fn expanded_west(&self, by: u16) -> Self {
315        Self::new(
316            self.lower_left_corner.to_owned() + GridCoordinateOffset::new(-i32::from(by), 0),
317            self.upper_right_corner.to_owned(),
318        )
319    }
320
321    /// returns a new `GridRectangle` extended by `by` regions on the east (+x) side
322    ///
323    /// Saturates at the eastern edge of the grid (x = `u16::MAX`).
324    #[must_use]
325    #[expect(
326        clippy::arithmetic_side_effects,
327        reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
328    )]
329    pub fn expanded_east(&self, by: u16) -> Self {
330        Self::new(
331            self.lower_left_corner.to_owned(),
332            self.upper_right_corner.to_owned() + GridCoordinateOffset::new(i32::from(by), 0),
333        )
334    }
335
336    /// returns a new `GridRectangle` extended by `by` regions on the south (-y) side
337    ///
338    /// Saturates at the southern edge of the grid (y = 0).
339    #[must_use]
340    #[expect(
341        clippy::arithmetic_side_effects,
342        reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
343    )]
344    pub fn expanded_south(&self, by: u16) -> Self {
345        Self::new(
346            self.lower_left_corner.to_owned() + GridCoordinateOffset::new(0, -i32::from(by)),
347            self.upper_right_corner.to_owned(),
348        )
349    }
350
351    /// returns a new `GridRectangle` extended by `by` regions on the north (+y) side
352    ///
353    /// Saturates at the northern edge of the grid (y = `u16::MAX`).
354    #[must_use]
355    #[expect(
356        clippy::arithmetic_side_effects,
357        reason = "GridCoordinates + GridCoordinateOffset saturates at u16::MIN/u16::MAX"
358    )]
359    pub fn expanded_north(&self, by: u16) -> Self {
360        Self::new(
361            self.lower_left_corner.to_owned(),
362            self.upper_right_corner.to_owned() + GridCoordinateOffset::new(0, i32::from(by)),
363        )
364    }
365}
366
367/// represents a grid rectangle like type (usually one that contains a
368/// grid rectangle or one that contains a corner and is of a known size
369pub trait GridRectangleLike {
370    /// the `GridRectangle` represented by this map like image
371    #[must_use]
372    fn grid_rectangle(&self) -> GridRectangle;
373
374    /// returns the lower left corner of the rectangle
375    #[must_use]
376    fn lower_left_corner(&self) -> GridCoordinates {
377        self.grid_rectangle().lower_left_corner().to_owned()
378    }
379
380    /// returns the lower right corner of the rectangle
381    #[must_use]
382    fn lower_right_corner(&self) -> GridCoordinates {
383        GridCoordinates::new(
384            self.grid_rectangle().upper_right_corner().x(),
385            self.grid_rectangle().lower_left_corner().y(),
386        )
387    }
388
389    /// returns the upper left corner of the rectangle
390    #[must_use]
391    fn upper_left_corner(&self) -> GridCoordinates {
392        GridCoordinates::new(
393            self.grid_rectangle().lower_left_corner().x(),
394            self.grid_rectangle().upper_right_corner().y(),
395        )
396    }
397
398    /// returns the upper right corner of the rectangle
399    #[must_use]
400    fn upper_right_corner(&self) -> GridCoordinates {
401        self.grid_rectangle().upper_right_corner().to_owned()
402    }
403
404    /// the size of the map like image in regions in the x direction (width)
405    #[must_use]
406    fn size_x(&self) -> u16 {
407        self.grid_rectangle().size_x()
408    }
409
410    /// the size of the map like image in regions in the y direction (width)
411    #[must_use]
412    fn size_y(&self) -> u16 {
413        self.grid_rectangle().size_y()
414    }
415
416    /// returns a range for the region x coordinates of this rectangle
417    #[must_use]
418    fn x_range(&self) -> std::ops::RangeInclusive<u16> {
419        self.lower_left_corner().x()..=self.upper_right_corner().x()
420    }
421
422    /// returns a range for the region y coordinates of this rectangle
423    #[must_use]
424    fn y_range(&self) -> std::ops::RangeInclusive<u16> {
425        self.lower_left_corner().y()..=self.upper_right_corner().y()
426    }
427
428    /// checks if a given set of `GridCoordinates` is within this `GridRectangle`
429    #[must_use]
430    fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
431        self.lower_left_corner().x() <= grid_coordinates.x()
432            && grid_coordinates.x() <= self.upper_right_corner().x()
433            && self.lower_left_corner().y() <= grid_coordinates.y()
434            && grid_coordinates.y() <= self.upper_right_corner().y()
435    }
436
437    /// returns a new `GridRectangle` which is the area where this `GridRectangle`
438    /// and another intersect each other or None if there is no intersection
439    #[must_use]
440    fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
441    where
442        O: GridRectangleLike,
443    {
444        let self_x_range: ranges::GenericRange<u16> = self.x_range().into();
445        let self_y_range: ranges::GenericRange<u16> = self.y_range().into();
446        let other_x_range: ranges::GenericRange<u16> = other.x_range().into();
447        let other_y_range: ranges::GenericRange<u16> = other.y_range().into();
448        let x_intersection = self_x_range.intersect(other_x_range);
449        let y_intersection = self_y_range.intersect(other_y_range);
450        match (x_intersection, y_intersection) {
451            (
452                ranges::OperationResult::Single(x_range),
453                ranges::OperationResult::Single(y_range),
454            ) => {
455                use std::ops::Bound;
456                use std::ops::RangeBounds as _;
457                match (
458                    x_range.start_bound(),
459                    x_range.end_bound(),
460                    y_range.start_bound(),
461                    y_range.end_bound(),
462                ) {
463                    (
464                        Bound::Included(start_x),
465                        Bound::Included(end_x),
466                        Bound::Included(start_y),
467                        Bound::Included(end_y),
468                    ) => Some(GridRectangle::new(
469                        GridCoordinates::new(*start_x, *start_y),
470                        GridCoordinates::new(*end_x, *end_y),
471                    )),
472                    _ => None,
473                }
474            }
475            _ => None,
476        }
477    }
478
479    /// returns a PPS HUD description string for this `GridRectangle`
480    ///
481    /// The PPS HUD is a map HUD commonly used in the SL sailing community
482    /// and usually you need to configure it by clicking on the HUD while
483    /// you are at the matching location in-world to calibrate the coordinates
484    /// on the map texture.
485    ///
486    /// This string needs to be put in the description of the PPS HUD
487    /// dot prim with "Edit linked objects" to avoid the need for manual
488    /// calibration.
489    #[must_use]
490    fn pps_hud_config(&self) -> String {
491        let lower_left_corner_x = 256f32 * f32::from(self.lower_left_corner().x());
492        let lower_left_corner_y = 256f32 * f32::from(self.lower_left_corner().y());
493        // this is basically the lower left corner as an LSL vector of meters from the grid coordinate origin
494        // followed by the width and height of the map in regions
495        // and a 0/1 for the locked state of the HUD
496        // each of those is separated from the next by a slash character
497        format!(
498            "<{lower_left_corner_x},{lower_left_corner_y},0>/{}/{}/1",
499            f32::from(self.size_x()),
500            f32::from(self.size_y())
501        )
502    }
503}
504
505impl GridRectangleLike for GridRectangle {
506    fn grid_rectangle(&self) -> GridRectangle {
507        self.to_owned()
508    }
509
510    fn lower_left_corner(&self) -> GridCoordinates {
511        self.lower_left_corner.to_owned()
512    }
513
514    fn upper_right_corner(&self) -> GridCoordinates {
515        self.upper_right_corner.to_owned()
516    }
517
518    fn size_x(&self) -> u16 {
519        self.upper_right_corner
520            .x()
521            .saturating_sub(self.lower_left_corner().x())
522            .saturating_add(1)
523    }
524
525    fn size_y(&self) -> u16 {
526        self.upper_right_corner
527            .y()
528            .saturating_sub(self.lower_left_corner().y())
529            .saturating_add(1)
530    }
531
532    fn x_range(&self) -> std::ops::RangeInclusive<u16> {
533        self.lower_left_corner.x()..=self.upper_right_corner.x()
534    }
535
536    fn y_range(&self) -> std::ops::RangeInclusive<u16> {
537        self.lower_left_corner.y()..=self.upper_right_corner.y()
538    }
539}
540
541impl GridRectangleLike for MapTileDescriptor {
542    fn grid_rectangle(&self) -> GridRectangle {
543        GridRectangle::new(
544            self.lower_left_corner,
545            GridCoordinates::new(
546                self.lower_left_corner
547                    .x()
548                    .saturating_add(self.zoom_level.tile_size())
549                    .saturating_sub(1),
550                self.lower_left_corner
551                    .y()
552                    .saturating_add(self.zoom_level.tile_size())
553                    .saturating_sub(1),
554            ),
555        )
556    }
557}
558
559/// A trait to allow adding methods to `Vec<GridCoordinates>`
560pub trait GridCoordinatesExt {
561    /// returns the coordinates of the lower left corner and the coordinates of
562    /// the upper right corner of a rectangle of regions containing all the grid
563    /// coordinates in this container
564    ///
565    /// returns None if the container is empty
566    fn bounding_rectangle(&self) -> Option<GridRectangle>;
567}
568
569impl GridCoordinatesExt for Vec<GridCoordinates> {
570    fn bounding_rectangle(&self) -> Option<GridRectangle> {
571        if self.is_empty() {
572            return None;
573        }
574        let (xs, ys): (Vec<u16>, Vec<u16>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
575        // unwrap is okay in these cases because we checked above that the container is non-empty
576        #[expect(
577            clippy::unwrap_used,
578            reason = "we checked above that the container is non-empty"
579        )]
580        let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
581        #[expect(
582            clippy::unwrap_used,
583            reason = "we checked above that the container is non-empty"
584        )]
585        let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
586        Some(GridRectangle {
587            lower_left_corner: GridCoordinates::new(*min_x, *min_y),
588            upper_right_corner: GridCoordinates::new(*max_x, *max_y),
589        })
590    }
591}
592
593/// Region coordinates for the position of something inside a region
594///
595/// Usually limited to 0..256 for x and y and 0..4096 for z (height)
596/// but values outside those ranges are possible for positions of objects
597/// in the process of crossing from one region to another or in similar
598/// situations where they belong to one simulator logically but are located
599/// outside of that simulator's region
600#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
601pub struct RegionCoordinates {
602    /// the x coordinate inside the region from the western edge (0) to the
603    /// eastern edge (256)
604    x: f32,
605    /// the y coordinate inside the region from the southern edge (0) to the
606    /// northern edge (256)
607    y: f32,
608    /// the z coordinate inside the region from the bottom (0) to the top (4096)
609    /// higher values are possible but for objects can not be rezzed above 4096m
610    /// and teleports are clamped to that as well
611    z: f32,
612}
613
614/// parse region coordinates
615///
616/// "{ 1.234, 2.345, 3.456 }"
617///
618/// # Errors
619///
620/// returns an error if the string could not be parsed
621#[cfg(feature = "chumsky")]
622#[must_use]
623pub fn region_coordinates_parser<'src>()
624-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
625{
626    just('{')
627        .ignore_then(whitespace().or_not())
628        .ignore_then(f32_parser())
629        .then_ignore(just(','))
630        .then_ignore(whitespace().or_not())
631        .then(f32_parser())
632        .then_ignore(just(','))
633        .then_ignore(whitespace().or_not())
634        .then(f32_parser())
635        .then_ignore(whitespace().or_not())
636        .then_ignore(just('}'))
637        .map(|((x, y), z)| RegionCoordinates::new(x, y, z))
638}
639
640impl RegionCoordinates {
641    /// Create a new `RegionCoordinates`
642    #[must_use]
643    pub const fn new(x: f32, y: f32, z: f32) -> Self {
644        Self { x, y, z }
645    }
646
647    /// The x coordinate inside the region
648    #[must_use]
649    pub const fn x(&self) -> f32 {
650        self.x
651    }
652
653    /// The y coordinate inside the region
654    #[must_use]
655    pub const fn y(&self) -> f32 {
656        self.y
657    }
658
659    /// The z coordinate inside the region
660    #[must_use]
661    pub const fn z(&self) -> f32 {
662        self.z
663    }
664
665    /// checks if the coordinates are within bounds
666    #[must_use]
667    pub fn in_bounds(&self) -> bool {
668        self.x >= 0f32
669            && self.x < 256f32
670            && self.y >= 0f32
671            && self.y < 256f32
672            && self.z >= 0f32
673            && self.z < 4096f32
674    }
675}
676
677impl From<crate::lsl::Vector> for RegionCoordinates {
678    fn from(value: crate::lsl::Vector) -> Self {
679        Self {
680            x: value.x,
681            y: value.y,
682            z: value.z,
683        }
684    }
685}
686
687/// The name of a region
688#[nutype::nutype(
689    sanitize(trim),
690    validate(len_char_min = 2, len_char_max = 35),
691    derive(
692        Debug,
693        Clone,
694        Display,
695        Hash,
696        PartialEq,
697        Eq,
698        PartialOrd,
699        Ord,
700        Serialize,
701        Deserialize,
702        AsRef
703    )
704)]
705pub struct RegionName(String);
706
707/// parse an url encoded string into a RegionName
708///
709/// # Errors
710///
711/// returns an error if the string could not be parsed
712#[cfg(feature = "chumsky")]
713#[must_use]
714pub fn url_region_name_parser<'src>()
715-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
716    url_text_component_parser().try_map(|region_name, span| {
717        RegionName::try_new(&region_name).map_err(|err| {
718            chumsky::error::Rich::custom(
719                span,
720                format!("failed to parse url-encoded region name ({region_name}): {err:?}"),
721            )
722        })
723    })
724}
725
726/// parse a string into a RegionName
727///
728/// # Errors
729///
730/// returns an error if the string could not be parsed
731#[cfg(feature = "chumsky")]
732#[must_use]
733pub fn region_name_parser<'src>()
734-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
735    any()
736        .filter(|c: &char| {
737            c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
738        })
739        .repeated()
740        .at_least(2)
741        .collect::<String>()
742        .try_map(|region_name, span| {
743            RegionName::try_new(&region_name).map_err(|err| {
744                chumsky::error::Rich::custom(
745                    span,
746                    format!("failed to parse region name ({region_name}): {err:?}"),
747                )
748            })
749        })
750}
751
752/// A location inside Second Life the way it is usually represented in
753/// SLURLs or map URLs, based on a Region Name and integer coordinates
754/// inside the region
755#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
756pub struct Location {
757    /// the name of the region of the location
758    pub region_name: RegionName,
759    /// the x coordinate inside the region
760    pub x: u8,
761    /// the y coordinate inside the region
762    pub y: u8,
763    /// the z coordinate inside the region
764    pub z: u16,
765}
766
767/// parse a string into a Location where no component is url-encoded
768///
769/// # Errors
770///
771/// returns an error if the string could not be parsed
772#[cfg(feature = "chumsky")]
773#[must_use]
774pub fn location_parser<'src>()
775-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
776    region_name_parser()
777        .then_ignore(just('/'))
778        .then(u8_parser())
779        .then_ignore(just('/'))
780        .then(u8_parser())
781        .then_ignore(just('/'))
782        .then(u16_parser())
783        .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
784}
785
786/// parse a string into a Location where the region name is url encoded
787/// but each component of the location is separated by an actual slash
788///
789/// # Errors
790///
791/// returns an error if the string could not be parsed
792#[cfg(feature = "chumsky")]
793#[must_use]
794pub fn url_location_parser<'src>()
795-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
796    url_region_name_parser()
797        .then_ignore(just('/'))
798        .then(u8_parser())
799        .then_ignore(just('/'))
800        .then(u8_parser())
801        .then_ignore(just('/'))
802        .then(u16_parser())
803        .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
804}
805
806/// parse a string into a Location from a URL-encoded location (the slashes in
807/// particular)
808///
809/// # Errors
810///
811/// returns an error if the string could not be parsed
812#[cfg(feature = "chumsky")]
813#[must_use]
814pub fn url_encoded_location_parser<'src>()
815-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
816    url_text_component_parser().try_map(|s, span| {
817        location_parser().parse(&s).into_result().map_err(|err| {
818            chumsky::error::Rich::custom(
819                span,
820                format!("Parsing {s} as location failed with: {err:#?}"),
821            )
822        })
823    })
824}
825
826/// the possible errors that can occur when parsing a String to a `Location`
827#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
828pub enum LocationParseError {
829    /// unexpected number of /-separated components in the location URL
830    #[error(
831        "unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
832    )]
833    UnexpectedComponentCount(String, usize),
834    /// unexpected scheme in the location URL
835    #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
836    UnexpectedScheme(String, String),
837    /// unexpected non-empty second component in location URL
838    #[error(
839        "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
840    )]
841    UnexpectedNonEmptySecondComponent(String, String),
842    /// unexpected host in the location URL
843    #[error(
844        "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
845    )]
846    UnexpectedHost(String, String),
847    /// unexpected path in the location URL
848    #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
849    UnexpectedPath(String, String),
850    /// error parsing the region name
851    #[error("error parsing the region name {0}: {1}")]
852    RegionName(String, RegionNameError),
853    /// error parsing the X coordinate
854    #[error("error parsing the X coordinate {0}: {1}")]
855    X(String, std::num::ParseIntError),
856    /// error parsing the Y coordinate
857    #[error("error parsing the Y coordinate {0}: {1}")]
858    Y(String, std::num::ParseIntError),
859    /// error parsing the Z coordinate
860    #[error("error parsing the Z coordinate {0}: {1}")]
861    Z(String, std::num::ParseIntError),
862}
863
864impl std::str::FromStr for Location {
865    type Err = LocationParseError;
866
867    fn from_str(s: &str) -> Result<Self, Self::Err> {
868        // if the string is an USB-notecard line drop everything after the first comma
869        let usb_location = s
870            .split_once(',')
871            .map_or(s, |(usb_location, _usb_comment)| usb_location);
872        let parts = usb_location.split('/').collect::<Vec<_>>();
873        if let [region_name, x, y, z] = parts.as_slice() {
874            let region_name = RegionName::try_new(region_name.replace("%20", " "))
875                .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
876            let x = x
877                .parse()
878                .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
879            let y = y
880                .parse()
881                .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
882            let z = z
883                .parse()
884                .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
885            return Ok(Self {
886                region_name,
887                x,
888                y,
889                z,
890            });
891        }
892        if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
893            if *scheme != "http:" && *scheme != "https:" {
894                return Err(LocationParseError::UnexpectedScheme(
895                    s.to_owned(),
896                    scheme.to_string(),
897                ));
898            }
899            if !second_component.is_empty() {
900                return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
901                    s.to_owned(),
902                    second_component.to_string(),
903                ));
904            }
905            if *host != "maps.secondlife.com" && *host != "slurl.com" {
906                return Err(LocationParseError::UnexpectedHost(
907                    s.to_owned(),
908                    host.to_string(),
909                ));
910            }
911            if *path != "secondlife" {
912                return Err(LocationParseError::UnexpectedPath(
913                    s.to_owned(),
914                    path.to_string(),
915                ));
916            }
917            let region_name = RegionName::try_new(region_name.replace("%20", " "))
918                .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
919            let x = x
920                .parse()
921                .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
922            let y = y
923                .parse()
924                .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
925            let z = z
926                .parse()
927                .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
928            return Ok(Self {
929                region_name,
930                x,
931                y,
932                z,
933            });
934        }
935        Err(LocationParseError::UnexpectedComponentCount(
936            s.to_owned(),
937            parts.len(),
938        ))
939    }
940}
941
942impl Location {
943    /// Creates a new `Location`
944    #[must_use]
945    pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
946        Self {
947            region_name,
948            x,
949            y,
950            z,
951        }
952    }
953
954    /// The region name of this `Location`
955    #[must_use]
956    pub const fn region_name(&self) -> &RegionName {
957        &self.region_name
958    }
959
960    /// The x coordinate of the `Location`
961    #[must_use]
962    pub const fn x(&self) -> u8 {
963        self.x
964    }
965
966    /// The y coordinate of the `Location`
967    #[must_use]
968    pub const fn y(&self) -> u8 {
969        self.y
970    }
971
972    /// The z coordinate of the `Location`
973    #[must_use]
974    pub const fn z(&self) -> u16 {
975        self.z
976    }
977
978    /// returns a maps.secondlife.com URL for the `Location`
979    #[must_use]
980    pub fn as_maps_url(&self) -> String {
981        format!(
982            "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
983            self.region_name, self.x, self.y, self.z
984        )
985    }
986}
987
988/// A location inside Second Life the way it is usually represented in
989/// SLURLs or map URLs, based on a Region Name and integer coordinates
990/// inside the region, this variant allows out of bounds coordinates
991/// (negative and 256 or above for x and y and negative for z)
992#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
993pub struct UnconstrainedLocation {
994    /// the name of the region of the location
995    pub region_name: RegionName,
996    /// the x coordinate inside the region
997    pub x: i16,
998    /// the y coordinate inside the region
999    pub y: i16,
1000    /// the z coordinate inside the region
1001    pub z: i32,
1002}
1003
1004impl UnconstrainedLocation {
1005    /// Creates a new `UnconstrainedLocation`
1006    #[must_use]
1007    pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
1008        Self {
1009            region_name,
1010            x,
1011            y,
1012            z,
1013        }
1014    }
1015
1016    /// The region name of this `UnconstrainedLocation`
1017    #[must_use]
1018    pub const fn region_name(&self) -> &RegionName {
1019        &self.region_name
1020    }
1021
1022    /// The x coordinate of the `UnconstrainedLocation`
1023    #[must_use]
1024    pub const fn x(&self) -> i16 {
1025        self.x
1026    }
1027
1028    /// The y coordinate of the `UnconstrainedLocation`
1029    #[must_use]
1030    pub const fn y(&self) -> i16 {
1031        self.y
1032    }
1033
1034    /// The z coordinate of the `UnconstrainedLocation`
1035    #[must_use]
1036    pub const fn z(&self) -> i32 {
1037        self.z
1038    }
1039}
1040
1041/// parse a string into an UnconstrainedLocation where nothing is urlencoded
1042///
1043/// # Errors
1044///
1045/// returns an error if the string could not be parsed
1046#[cfg(feature = "chumsky")]
1047#[must_use]
1048pub fn unconstrained_location_parser<'src>() -> impl Parser<
1049    'src,
1050    &'src str,
1051    UnconstrainedLocation,
1052    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1053> {
1054    region_name_parser()
1055        .then_ignore(just('/'))
1056        .then(i16_parser())
1057        .then_ignore(just('/'))
1058        .then(i16_parser())
1059        .then_ignore(just('/'))
1060        .then(i32_parser())
1061        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1062}
1063
1064/// parse a string into an UnconstrainedLocation where the region is urlencoded
1065/// but the components are separated by actual slashes
1066///
1067/// # Errors
1068///
1069/// returns an error if the string could not be parsed
1070#[cfg(feature = "chumsky")]
1071#[must_use]
1072pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1073    'src,
1074    &'src str,
1075    UnconstrainedLocation,
1076    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1077> {
1078    url_region_name_parser()
1079        .then_ignore(just('/'))
1080        .then(i16_parser())
1081        .then_ignore(just('/'))
1082        .then(i16_parser())
1083        .then_ignore(just('/'))
1084        .then(i32_parser())
1085        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1086}
1087
1088/// parse a string into an UnconstrainedLocation where the entire location is
1089/// urlencoded with urlencoded slashes
1090///
1091/// # Errors
1092///
1093/// returns an error if the string could not be parsed
1094#[cfg(feature = "chumsky")]
1095#[must_use]
1096pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1097    'src,
1098    &'src str,
1099    UnconstrainedLocation,
1100    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1101> {
1102    url_region_name_parser()
1103        .then_ignore(just('/'))
1104        .then(i16_parser())
1105        .then_ignore(just('/'))
1106        .then(i16_parser())
1107        .then_ignore(just('/'))
1108        .then(i32_parser())
1109        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1110}
1111
1112impl TryFrom<UnconstrainedLocation> for Location {
1113    type Error = std::num::TryFromIntError;
1114
1115    fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1116        Ok(Self::new(
1117            value.region_name,
1118            value.x.try_into()?,
1119            value.y.try_into()?,
1120            value.z.try_into()?,
1121        ))
1122    }
1123}
1124
1125impl From<Location> for UnconstrainedLocation {
1126    fn from(value: Location) -> Self {
1127        Self {
1128            region_name: value.region_name,
1129            x: value.x.into(),
1130            y: value.y.into(),
1131            z: value.z.into(),
1132        }
1133    }
1134}
1135
1136/// The map tile zoom level for the Second Life main map
1137#[nutype::nutype(
1138    validate(greater_or_equal = 1, less_or_equal = 8),
1139    derive(
1140        Debug,
1141        Clone,
1142        Copy,
1143        Display,
1144        FromStr,
1145        Hash,
1146        PartialEq,
1147        Eq,
1148        PartialOrd,
1149        Ord,
1150        Serialize,
1151        Deserialize
1152    )
1153)]
1154pub struct ZoomLevel(u8);
1155
1156/// Errors that can occur when trying to find the correct zoom level to fit
1157/// regions into an output image of a given size
1158#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1159pub enum ZoomFitError {
1160    /// The region size in the x direction can not be zero
1161    #[error("region size in x direction can not be zero")]
1162    RegionSizeXZero,
1163
1164    /// The region size in the y direction can not be zero
1165    #[error("region size in y direction can not be zero")]
1166    RegionSizeYZero,
1167
1168    /// The output image size in the x direction can not be zero
1169    #[error("output image size in x direction can not be zero")]
1170    OutputSizeXZero,
1171
1172    /// The output image size in the y direction can not be zero
1173    #[error("output image size in y direction can not be zero")]
1174    OutputSizeYZero,
1175
1176    /// Error converting a logarithm value into a `u8` (should never happen)
1177    #[error("error converting a logarithm value into a u8")]
1178    LogarithmConversionError(#[from] std::num::TryFromIntError),
1179
1180    /// Error creating the zoom level from the calculated value
1181    /// (should never happen)
1182    #[error("error creating zoom level from calculated value")]
1183    ZoomLevelError(#[from] ZoomLevelError),
1184}
1185
1186impl ZoomLevel {
1187    /// returns the map tile size in number of regions at this zoom level
1188    ///
1189    /// This applies to both dimensions equally since both regions and map tiles
1190    /// are square
1191    #[must_use]
1192    pub fn tile_size(&self) -> u16 {
1193        let exponent: u32 = self.into_inner().into();
1194        let exponent = exponent.saturating_sub(1);
1195        2u16.pow(exponent)
1196    }
1197
1198    /// returns the map tile size in pixels at this zoom level
1199    ///
1200    /// This applies to both dimensions equally since both regions and map tiles
1201    /// are square
1202    #[expect(
1203        clippy::arithmetic_side_effects,
1204        reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1205    )]
1206    #[must_use]
1207    pub fn tile_size_in_pixels(&self) -> u32 {
1208        let tile_size: u32 = self.tile_size().into();
1209        let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1210        tile_size * region_size_in_map_tile_in_pixels
1211    }
1212
1213    /// returns the lower left (lowest coordinate for each axis) coordinate of
1214    /// the map tile containing the given grid coordinates at this zoom level
1215    ///
1216    /// That is the coordinates used for the file name of the map tile at this
1217    /// zoom level that contains the region (or gap where a region could be)
1218    /// given by the grid coordinates
1219    #[must_use]
1220    pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1221        let tile_size = self.tile_size();
1222        #[expect(
1223            clippy::arithmetic_side_effects,
1224            reason = "remainder should not have any side-effects since tile_size is never 0 (no division by zero issues) or negative (no issues with x or y being e.g. i16::MIN which overflows when the sign is flipped)"
1225        )]
1226        GridCoordinates {
1227            x: x.saturating_sub(x % tile_size),
1228            y: y.saturating_sub(y % tile_size),
1229        }
1230    }
1231
1232    /// returns the size of a region in pixels in a map tile of this zoom level
1233    ///
1234    /// The size applies to both dimensions equally since both regions and map tiles
1235    /// are square
1236    #[must_use]
1237    pub fn pixels_per_region(&self) -> u16 {
1238        let exponent: u32 = self.into_inner().into();
1239        let exponent = exponent.saturating_sub(1);
1240        let exponent = 8u32.saturating_sub(exponent);
1241        2u16.pow(exponent)
1242    }
1243
1244    /// returns the number of pixels per meter at this zoom level
1245    #[must_use]
1246    pub fn pixels_per_meter(&self) -> f32 {
1247        f32::from(self.pixels_per_region()) / 256f32
1248    }
1249
1250    /// returns the zoom level that is the highest zoom level that makes sense
1251    /// to use if we want to fit a given area of regions into a given image size
1252    /// assuming we want to always have one map tile pixel on one output pixel
1253    ///
1254    /// # Errors
1255    ///
1256    /// returns an error if any of the parameters are zero or in the (theoretically
1257    /// impossible if the algorithm is correct) case that ZoomLevel::try_new()
1258    /// returns an error on the calculated value
1259    pub fn max_zoom_level_to_fit_regions_into_output_image(
1260        region_x: u16,
1261        region_y: u16,
1262        output_x: u32,
1263        output_y: u32,
1264    ) -> Result<Self, ZoomFitError> {
1265        if region_x == 0 {
1266            return Err(ZoomFitError::RegionSizeXZero);
1267        }
1268        if region_y == 0 {
1269            return Err(ZoomFitError::RegionSizeYZero);
1270        }
1271        if output_x == 0 {
1272            return Err(ZoomFitError::OutputSizeXZero);
1273        }
1274        if output_y == 0 {
1275            return Err(ZoomFitError::OutputSizeYZero);
1276        }
1277        let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x.into());
1278        let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y.into());
1279        let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1280            8,
1281            output_pixels_per_region_x
1282                .ilog2()
1283                .try_into()
1284                .map_err(ZoomFitError::LogarithmConversionError)?,
1285        ));
1286        let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1287            8,
1288            output_pixels_per_region_y
1289                .ilog2()
1290                .try_into()
1291                .map_err(ZoomFitError::LogarithmConversionError)?,
1292        ));
1293        Ok(Self::try_new(std::cmp::max(
1294            max_zoom_level_x,
1295            max_zoom_level_y,
1296        ))?)
1297    }
1298}
1299
1300/// describes a map tile
1301#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1302#[expect(
1303    clippy::module_name_repetitions,
1304    reason = "the type is used outside this module"
1305)]
1306pub struct MapTileDescriptor {
1307    /// the zoom level of the map tile
1308    zoom_level: ZoomLevel,
1309    /// the lower left corner of the map tile
1310    lower_left_corner: GridCoordinates,
1311}
1312
1313impl MapTileDescriptor {
1314    /// create a new `MapTileDescriptor`
1315    ///
1316    /// this will automatically normalize the given `GridCoordinates` to the
1317    /// lower left corner of a map tile at that zoom level
1318    #[must_use]
1319    pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1320        let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1321        Self {
1322            zoom_level,
1323            lower_left_corner,
1324        }
1325    }
1326
1327    /// the `ZoomLevel` of the map tile
1328    #[must_use]
1329    pub const fn zoom_level(&self) -> &ZoomLevel {
1330        &self.zoom_level
1331    }
1332
1333    /// the `GridCoordinates` of the lower left corner of this map tile
1334    #[must_use]
1335    pub const fn lower_left_corner(&self) -> &GridCoordinates {
1336        &self.lower_left_corner
1337    }
1338
1339    /// the size of this map tile in regions
1340    #[must_use]
1341    pub fn tile_size(&self) -> u16 {
1342        self.zoom_level.tile_size()
1343    }
1344
1345    /// the size of this map tile in pixels
1346    #[must_use]
1347    pub fn tile_size_in_pixels(&self) -> u32 {
1348        self.zoom_level.tile_size_in_pixels()
1349    }
1350
1351    /// the grid rectangle covered by this map tile
1352    #[must_use]
1353    pub fn grid_rectangle(&self) -> GridRectangle {
1354        GridRectangle::new(
1355            self.lower_left_corner,
1356            GridCoordinates::new(
1357                self.lower_left_corner
1358                    .x()
1359                    .saturating_add(self.zoom_level.tile_size())
1360                    .saturating_sub(1),
1361                self.lower_left_corner
1362                    .y()
1363                    .saturating_add(self.zoom_level.tile_size())
1364                    .saturating_sub(1),
1365            ),
1366        )
1367    }
1368}
1369
1370/// A waypoint in the Universal Sailor Buddy (USB) notecard format
1371#[derive(Debug, Clone)]
1372pub struct USBWaypoint {
1373    /// the location of the waypoint
1374    location: Location,
1375    /// the comment for the waypoint if any
1376    comment: Option<String>,
1377}
1378
1379impl USBWaypoint {
1380    /// Create a new USB waypoint
1381    #[must_use]
1382    pub const fn new(location: Location, comment: Option<String>) -> Self {
1383        Self { location, comment }
1384    }
1385
1386    /// get the location of the waypoint
1387    #[must_use]
1388    pub const fn location(&self) -> &Location {
1389        &self.location
1390    }
1391
1392    /// get the region coordinates of the waypoint
1393    #[must_use]
1394    pub fn region_coordinates(&self) -> RegionCoordinates {
1395        RegionCoordinates::new(
1396            f32::from(self.location.x()),
1397            f32::from(self.location.y()),
1398            f32::from(self.location.z()),
1399        )
1400    }
1401
1402    /// get the comment for the waypoint if any
1403    #[must_use]
1404    pub const fn comment(&self) -> Option<&String> {
1405        self.comment.as_ref()
1406    }
1407}
1408
1409impl std::fmt::Display for USBWaypoint {
1410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1411        write!(f, "{}", self.location.as_maps_url())?;
1412        if let Some(comment) = &self.comment {
1413            write!(f, ",{comment}")?;
1414        }
1415        Ok(())
1416    }
1417}
1418
1419impl std::str::FromStr for USBWaypoint {
1420    type Err = LocationParseError;
1421
1422    fn from_str(s: &str) -> Result<Self, Self::Err> {
1423        if let Some((location, comment)) = s.split_once(',') {
1424            Ok(Self {
1425                location: location.parse()?,
1426                comment: Some(comment.to_owned()),
1427            })
1428        } else {
1429            Ok(Self {
1430                location: s.parse()?,
1431                comment: None,
1432            })
1433        }
1434    }
1435}
1436
1437/// An Universal Sailor Buddy (USB) notecard
1438#[derive(Debug, Clone)]
1439pub struct USBNotecard {
1440    /// the waypoints in the notecard
1441    waypoints: Vec<USBWaypoint>,
1442}
1443
1444/// Errors that can happen when an USB notecard is read from a file
1445#[derive(Debug, thiserror::Error, strum::EnumIs)]
1446pub enum USBNotecardLoadError {
1447    /// I/O errors opening or reading the file
1448    #[error("I/O error opening or reading the file: {0}")]
1449    Io(#[from] std::io::Error),
1450    /// Parse error deserializing the USB notecard lines
1451    #[error("parse error deserializing the USB notecard lines: {0}")]
1452    LocationParseError(#[from] LocationParseError),
1453}
1454
1455impl USBNotecard {
1456    /// Create a new USB notecard
1457    #[must_use]
1458    pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1459        Self { waypoints }
1460    }
1461
1462    /// get the waypoints in the notecard
1463    #[must_use]
1464    pub fn waypoints(&self) -> &[USBWaypoint] {
1465        &self.waypoints
1466    }
1467
1468    /// load an USB Notecard from a text file
1469    ///
1470    /// # Errors
1471    ///
1472    /// this returns an error if either reading the file or parsing the content
1473    /// as a `USBNotecard` fail
1474    pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1475        let contents = std::fs::read_to_string(filename)?;
1476        Ok(contents.parse()?)
1477    }
1478}
1479
1480impl std::fmt::Display for USBNotecard {
1481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1482        for waypoint in &self.waypoints {
1483            writeln!(f, "{waypoint}")?;
1484        }
1485        Ok(())
1486    }
1487}
1488
1489impl std::str::FromStr for USBNotecard {
1490    type Err = LocationParseError;
1491
1492    fn from_str(s: &str) -> Result<Self, Self::Err> {
1493        s.lines()
1494            .map(|line| line.parse::<USBWaypoint>())
1495            .collect::<Result<Vec<_>, _>>()
1496            .map(|waypoints| Self { waypoints })
1497    }
1498}
1499
1500#[cfg(test)]
1501mod test {
1502    use super::*;
1503    use pretty_assertions::assert_eq;
1504
1505    #[test]
1506    fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1507        assert_eq!(
1508            "Beach%20Valley/110/67/24".parse::<Location>(),
1509            Ok(Location {
1510                region_name: RegionName::try_new("Beach Valley")?,
1511                x: 110,
1512                y: 67,
1513                z: 24
1514            }),
1515        );
1516        Ok(())
1517    }
1518
1519    #[test]
1520    fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1521        assert_eq!(
1522            "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1523            Ok(Location {
1524                region_name: RegionName::try_new("Beach Valley")?,
1525                x: 110,
1526                y: 67,
1527                z: 24
1528            }),
1529        );
1530        Ok(())
1531    }
1532
1533    #[test]
1534    fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1535        assert_eq!(
1536            "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1537            Ok(Location {
1538                region_name: RegionName::try_new("Beach Valley")?,
1539                x: 110,
1540                y: 67,
1541                z: 24
1542            }),
1543        );
1544        Ok(())
1545    }
1546
1547    #[test]
1548    fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
1549        assert_eq!(
1550            "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
1551            Ok(Location {
1552                region_name: RegionName::try_new("Beach Valley")?,
1553                x: 110,
1554                y: 67,
1555                z: 24
1556            }),
1557        );
1558        Ok(())
1559    }
1560
1561    #[test]
1562    fn test_grid_rectangle_intersection_upper_right_corner()
1563    -> Result<(), Box<dyn std::error::Error>> {
1564        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1565        let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
1566        assert_eq!(
1567            rect1.intersect(&rect2),
1568            Some(GridRectangle::new(
1569                GridCoordinates::new(15, 15),
1570                GridCoordinates::new(20, 20),
1571            ))
1572        );
1573        Ok(())
1574    }
1575
1576    #[test]
1577    fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
1578    {
1579        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1580        let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
1581        assert_eq!(
1582            rect1.intersect(&rect2),
1583            Some(GridRectangle::new(
1584                GridCoordinates::new(10, 15),
1585                GridCoordinates::new(15, 20),
1586            ))
1587        );
1588        Ok(())
1589    }
1590
1591    #[test]
1592    fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
1593    {
1594        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1595        let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
1596        assert_eq!(
1597            rect1.intersect(&rect2),
1598            Some(GridRectangle::new(
1599                GridCoordinates::new(10, 10),
1600                GridCoordinates::new(15, 15),
1601            ))
1602        );
1603        Ok(())
1604    }
1605
1606    #[test]
1607    fn test_grid_rectangle_intersection_lower_right_corner()
1608    -> Result<(), Box<dyn std::error::Error>> {
1609        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1610        let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
1611        assert_eq!(
1612            rect1.intersect(&rect2),
1613            Some(GridRectangle::new(
1614                GridCoordinates::new(15, 10),
1615                GridCoordinates::new(20, 15),
1616            ))
1617        );
1618        Ok(())
1619    }
1620
1621    #[test]
1622    fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
1623        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1624        let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
1625        assert_eq!(rect1.intersect(&rect2), None);
1626        Ok(())
1627    }
1628
1629    #[test]
1630    fn test_grid_rectangle_expanded_west() {
1631        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1632        assert_eq!(rect.expanded_west(0), rect);
1633        assert_eq!(
1634            rect.expanded_west(3),
1635            GridRectangle::new(GridCoordinates::new(7, 10), GridCoordinates::new(20, 20)),
1636        );
1637        let near_edge =
1638            GridRectangle::new(GridCoordinates::new(2, 10), GridCoordinates::new(20, 20));
1639        assert_eq!(
1640            near_edge.expanded_west(5),
1641            GridRectangle::new(GridCoordinates::new(0, 10), GridCoordinates::new(20, 20)),
1642        );
1643    }
1644
1645    #[test]
1646    fn test_grid_rectangle_expanded_east() {
1647        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1648        assert_eq!(rect.expanded_east(0), rect);
1649        assert_eq!(
1650            rect.expanded_east(3),
1651            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(23, 20)),
1652        );
1653        let near_edge = GridRectangle::new(
1654            GridCoordinates::new(10, 10),
1655            GridCoordinates::new(u16::MAX - 2, 20),
1656        );
1657        assert_eq!(
1658            near_edge.expanded_east(5),
1659            GridRectangle::new(
1660                GridCoordinates::new(10, 10),
1661                GridCoordinates::new(u16::MAX, 20),
1662            ),
1663        );
1664    }
1665
1666    #[test]
1667    fn test_grid_rectangle_expanded_south() {
1668        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1669        assert_eq!(rect.expanded_south(0), rect);
1670        assert_eq!(
1671            rect.expanded_south(3),
1672            GridRectangle::new(GridCoordinates::new(10, 7), GridCoordinates::new(20, 20)),
1673        );
1674        let near_edge =
1675            GridRectangle::new(GridCoordinates::new(10, 2), GridCoordinates::new(20, 20));
1676        assert_eq!(
1677            near_edge.expanded_south(5),
1678            GridRectangle::new(GridCoordinates::new(10, 0), GridCoordinates::new(20, 20)),
1679        );
1680    }
1681
1682    #[test]
1683    fn test_grid_rectangle_expanded_north() {
1684        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1685        assert_eq!(rect.expanded_north(0), rect);
1686        assert_eq!(
1687            rect.expanded_north(3),
1688            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 23)),
1689        );
1690        let near_edge = GridRectangle::new(
1691            GridCoordinates::new(10, 10),
1692            GridCoordinates::new(20, u16::MAX - 2),
1693        );
1694        assert_eq!(
1695            near_edge.expanded_north(5),
1696            GridRectangle::new(
1697                GridCoordinates::new(10, 10),
1698                GridCoordinates::new(20, u16::MAX),
1699            ),
1700        );
1701    }
1702
1703    #[cfg(feature = "chumsky")]
1704    #[test]
1705    fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1706        let region_name = "Viterbo";
1707        assert_eq!(
1708            url_region_name_parser().parse(region_name).into_result(),
1709            Ok(RegionName::try_new(region_name)?)
1710        );
1711        Ok(())
1712    }
1713
1714    #[cfg(feature = "chumsky")]
1715    #[test]
1716    fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1717        let region_name = "Da Boom";
1718        let input = region_name.replace(' ', "%20");
1719        assert_eq!(
1720            url_region_name_parser().parse(&input).into_result(),
1721            Ok(RegionName::try_new(region_name)?)
1722        );
1723        Ok(())
1724    }
1725
1726    #[cfg(feature = "chumsky")]
1727    #[test]
1728    fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1729        let region_name = "Da Boom";
1730        assert_eq!(
1731            region_name_parser().parse(region_name).into_result(),
1732            Ok(RegionName::try_new(region_name)?)
1733        );
1734        Ok(())
1735    }
1736
1737    #[cfg(feature = "chumsky")]
1738    #[test]
1739    fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1740        let region_name = "Viterbo";
1741        let input = format!("{region_name}/1/2/300");
1742        assert_eq!(
1743            url_location_parser().parse(&input).into_result(),
1744            Ok(Location {
1745                region_name: RegionName::try_new(region_name)?,
1746                x: 1,
1747                y: 2,
1748                z: 300
1749            })
1750        );
1751        Ok(())
1752    }
1753
1754    #[cfg(feature = "chumsky")]
1755    #[test]
1756    fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
1757        let region_name = "Da Boom";
1758        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1759        assert_eq!(
1760            url_location_parser().parse(&input).into_result(),
1761            Ok(Location {
1762                region_name: RegionName::try_new(region_name)?,
1763                x: 1,
1764                y: 2,
1765                z: 300
1766            })
1767        );
1768        Ok(())
1769    }
1770
1771    #[cfg(feature = "chumsky")]
1772    #[test]
1773    fn test_url_location_parser_url_whitespace_single_digit_after_space()
1774    -> Result<(), Box<dyn std::error::Error>> {
1775        let region_name = "Foo Bar 3";
1776        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
1777        assert_eq!(
1778            url_location_parser().parse(&input).into_result(),
1779            Ok(Location {
1780                region_name: RegionName::try_new(region_name)?,
1781                x: 1,
1782                y: 2,
1783                z: 300
1784            })
1785        );
1786        Ok(())
1787    }
1788
1789    #[cfg(feature = "chumsky")]
1790    #[test]
1791    fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
1792        assert_eq!(
1793            region_coordinates_parser()
1794                .parse("{ 63.0486, 45.2515, 1501.08 }")
1795                .into_result(),
1796            Ok(RegionCoordinates {
1797                x: 63.0486,
1798                y: 45.2515,
1799                z: 1501.08,
1800            })
1801        );
1802        Ok(())
1803    }
1804}