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, u32_parser,
13    url_text_component_parser,
14};
15
16/// represents a Second Life distance in meters
17#[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
18pub struct Distance(f64);
19
20impl Distance {
21    /// creates a distance from a value in meters
22    #[must_use]
23    pub const fn new(meters: f64) -> Self {
24        Self(meters)
25    }
26
27    /// the distance in meters
28    #[must_use]
29    pub const fn meters(&self) -> f64 {
30        self.0
31    }
32}
33
34impl std::fmt::Display for Distance {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{} m", self.0)
37    }
38}
39
40impl std::ops::Add for Distance {
41    type Output = Self;
42
43    fn add(self, rhs: Self) -> Self::Output {
44        Self(self.0 + rhs.0)
45    }
46}
47
48impl std::ops::Sub for Distance {
49    type Output = Self;
50
51    fn sub(self, rhs: Self) -> Self::Output {
52        Self(self.0 - rhs.0)
53    }
54}
55
56impl std::ops::Mul<u8> for Distance {
57    type Output = Self;
58
59    fn mul(self, rhs: u8) -> Self::Output {
60        Self(self.0 * f64::from(rhs))
61    }
62}
63
64impl std::ops::Mul<u16> for Distance {
65    type Output = Self;
66
67    fn mul(self, rhs: u16) -> Self::Output {
68        Self(self.0 * f64::from(rhs))
69    }
70}
71
72impl std::ops::Mul<u32> for Distance {
73    type Output = Self;
74
75    fn mul(self, rhs: u32) -> Self::Output {
76        Self(self.0 * f64::from(rhs))
77    }
78}
79
80impl std::ops::Mul<f32> for Distance {
81    type Output = Self;
82
83    fn mul(self, rhs: f32) -> Self::Output {
84        Self(self.0 * f64::from(rhs))
85    }
86}
87
88impl std::ops::Mul<f64> for Distance {
89    type Output = Self;
90
91    fn mul(self, rhs: f64) -> Self::Output {
92        Self(self.0 * rhs)
93    }
94}
95
96impl std::ops::Div<u8> for Distance {
97    type Output = Self;
98
99    fn div(self, rhs: u8) -> Self::Output {
100        Self(self.0 / f64::from(rhs))
101    }
102}
103
104impl std::ops::Div<u16> for Distance {
105    type Output = Self;
106
107    fn div(self, rhs: u16) -> Self::Output {
108        Self(self.0 / f64::from(rhs))
109    }
110}
111
112impl std::ops::Div<u32> for Distance {
113    type Output = Self;
114
115    fn div(self, rhs: u32) -> Self::Output {
116        Self(self.0 / f64::from(rhs))
117    }
118}
119
120impl std::ops::Div<f32> for Distance {
121    type Output = Self;
122
123    fn div(self, rhs: f32) -> Self::Output {
124        Self(self.0 / f64::from(rhs))
125    }
126}
127
128impl std::ops::Div<f64> for Distance {
129    type Output = Self;
130
131    fn div(self, rhs: f64) -> Self::Output {
132        Self(self.0 / rhs)
133    }
134}
135
136impl std::ops::Div for Distance {
137    type Output = f64;
138
139    fn div(self, rhs: Self) -> Self::Output {
140        self.0 / rhs.0
141    }
142}
143
144impl std::ops::Rem<u8> for Distance {
145    type Output = Self;
146
147    fn rem(self, rhs: u8) -> Self::Output {
148        Self(self.0 % f64::from(rhs))
149    }
150}
151
152impl std::ops::Rem<u16> for Distance {
153    type Output = Self;
154
155    fn rem(self, rhs: u16) -> Self::Output {
156        Self(self.0 % f64::from(rhs))
157    }
158}
159
160impl std::ops::Rem<u32> for Distance {
161    type Output = Self;
162
163    fn rem(self, rhs: u32) -> Self::Output {
164        Self(self.0 % f64::from(rhs))
165    }
166}
167
168impl std::ops::Rem<f32> for Distance {
169    type Output = Self;
170
171    fn rem(self, rhs: f32) -> Self::Output {
172        Self(self.0 % f64::from(rhs))
173    }
174}
175
176impl std::ops::Rem<f64> for Distance {
177    type Output = Self;
178
179    fn rem(self, rhs: f64) -> Self::Output {
180        Self(self.0 % rhs)
181    }
182}
183
184/// parse a distance
185///
186/// "235.23 m"
187///
188/// # Errors
189///
190/// returns an error if the string could not be parsed
191#[cfg(feature = "chumsky")]
192#[must_use]
193pub fn distance_parser<'src>()
194-> impl Parser<'src, &'src str, Distance, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
195    crate::utils::unsigned_f64_parser()
196        .then_ignore(whitespace().or_not())
197        .then_ignore(just('m'))
198        .map(Distance)
199}
200
201/// A Second Life land area, in **square metres** — the unit SL measures parcels
202/// and land-tier accounting in (a member's group land contribution, a parcel's
203/// actual/billable area, an avatar's land credit/commitment, …).
204///
205/// This is deliberately **not** an [`LindenAmount`](crate::money::LindenAmount):
206/// land areas occupy the same signed-32-bit integer slots prices use, and the
207/// two are trivially confusable as raw integers. Wrapping area in its own
208/// newtype makes "passed a land area where an L$ price was expected" (and
209/// vice-versa) a compile error. A land area is non-negative by construction.
210#[derive(
211    Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
212)]
213pub struct LandArea(pub u32);
214
215impl LandArea {
216    /// A zero land area.
217    pub const ZERO: Self = Self(0);
218
219    /// The wrapped count of square metres.
220    #[must_use]
221    pub const fn get(&self) -> u32 {
222        self.0
223    }
224}
225
226impl std::fmt::Display for LandArea {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        let Self(value) = self;
229        write!(f, "{value} m²")
230    }
231}
232
233impl std::ops::Add for LandArea {
234    type Output = Self;
235
236    fn add(self, rhs: Self) -> Self::Output {
237        let Self(lhs) = self;
238        let Self(rhs) = rhs;
239        #[expect(
240            clippy::arithmetic_side_effects,
241            reason = "the same overflow behaviour as the underlying integer addition, which is what a caller summing areas expects"
242        )]
243        Self(lhs + rhs)
244    }
245}
246
247impl std::ops::Sub for LandArea {
248    type Output = Self;
249
250    fn sub(self, rhs: Self) -> Self::Output {
251        let Self(lhs) = self;
252        let Self(rhs) = rhs;
253        #[expect(
254            clippy::arithmetic_side_effects,
255            reason = "the same underflow behaviour as the underlying integer subtraction, which is what a caller differencing areas expects"
256        )]
257        Self(lhs - rhs)
258    }
259}
260
261/// parse a land area
262///
263/// "1024 m²"
264///
265/// # Errors
266///
267/// returns an error if the string could not be parsed
268#[cfg(feature = "chumsky")]
269#[must_use]
270pub fn land_area_parser<'src>()
271-> impl Parser<'src, &'src str, LandArea, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
272    u32_parser()
273        .then_ignore(whitespace().or_not())
274        .then_ignore(just("m²"))
275        .map(LandArea)
276}
277
278/// Grid coordinates for the position of a region on the map
279///
280/// the first region, Da Boom is located at 1000, 1000
281#[derive(
282    Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
283)]
284pub struct GridCoordinates {
285    /// the x coordinate of the region, this is basically the horizontal
286    /// position of the region on the map increasing from west to east
287    ///
288    /// common values are between roughly 395 and 1358; the type is `u32` (not
289    /// `u16`) because the Second Life whole-grid map layer reports rectangle
290    /// bounds that can exceed `u16::MAX`
291    x: u32,
292    /// the y coordinate of the region, this is basically the vertical
293    /// position of the region on the map increasing from south to north
294    ///
295    /// common values are between roughly 479 and 1430; the type is `u32` (not
296    /// `u16`) because the Second Life whole-grid map layer reports rectangle
297    /// bounds that can exceed `u16::MAX`
298    y: u32,
299}
300
301impl GridCoordinates {
302    /// Create a new `GridCoordinates`
303    #[must_use]
304    pub const fn new(x: u32, y: u32) -> Self {
305        Self { x, y }
306    }
307
308    /// The x coordinate of the region
309    #[must_use]
310    pub const fn x(&self) -> u32 {
311        self.x
312    }
313
314    /// The y coordinate of the region
315    #[must_use]
316    pub const fn y(&self) -> u32 {
317        self.y
318    }
319}
320
321/// an offset between two `GridCoordinates`
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct GridCoordinateOffset {
324    /// the offset in the x direction
325    x: i32,
326    /// the offset in the y direction
327    y: i32,
328}
329
330impl GridCoordinateOffset {
331    /// creates a new `GridCoordinateOffset`
332    #[must_use]
333    pub const fn new(x: i32, y: i32) -> Self {
334        Self { x, y }
335    }
336
337    /// the offset in the x direction
338    #[must_use]
339    pub const fn x(&self) -> i32 {
340        self.x
341    }
342
343    /// the offset in the y direction
344    #[must_use]
345    pub const fn y(&self) -> i32 {
346        self.y
347    }
348}
349
350impl std::ops::Add<GridCoordinateOffset> for GridCoordinates {
351    type Output = Self;
352
353    fn add(self, rhs: GridCoordinateOffset) -> Self::Output {
354        Self::new(
355            (i64::from(self.x).saturating_add(i64::from(rhs.x)))
356                .try_into()
357                .unwrap_or(if rhs.x > 0 { u32::MAX } else { u32::MIN }),
358            (i64::from(self.y).saturating_add(i64::from(rhs.y)))
359                .try_into()
360                .unwrap_or(if rhs.y > 0 { u32::MAX } else { u32::MIN }),
361        )
362    }
363}
364
365impl std::ops::Sub<Self> for GridCoordinates {
366    type Output = GridCoordinateOffset;
367
368    fn sub(self, rhs: Self) -> Self::Output {
369        /// Saturates the `i64` difference of two `u32` coordinates into the
370        /// `i32` an offset holds (real grid differences never approach `i32`'s
371        /// range, but the conversion must still be total).
372        fn saturate_to_i32(difference: i64) -> i32 {
373            difference
374                .try_into()
375                .unwrap_or(if difference > 0 { i32::MAX } else { i32::MIN })
376        }
377        GridCoordinateOffset::new(
378            saturate_to_i32(i64::from(self.x).saturating_sub(i64::from(rhs.x))),
379            saturate_to_i32(i64::from(self.y).saturating_sub(i64::from(rhs.y))),
380        )
381    }
382}
383
384/// represents a rectangle of regions defined by the lower left (minimum coordinates)
385/// and upper right (maximum coordinates) corners in `GridCoordinates`
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct GridRectangle {
388    /// the lower left (minimum coordinates) corner of the rectangle
389    lower_left_corner: GridCoordinates,
390    /// the upper right (maximum coordinates) corner of the rectangle
391    upper_right_corner: GridCoordinates,
392}
393
394impl GridRectangle {
395    /// creates a new `GridRectangle` given any two corners
396    #[must_use]
397    pub fn new(corner1: GridCoordinates, corner2: GridCoordinates) -> Self {
398        Self {
399            lower_left_corner: GridCoordinates::new(
400                corner1.x().min(corner2.x()),
401                corner1.y().min(corner2.y()),
402            ),
403            upper_right_corner: GridCoordinates::new(
404                corner1.x().max(corner2.x()),
405                corner1.y().max(corner2.y()),
406            ),
407        }
408    }
409
410    /// returns a new `GridRectangle` extended by `by` regions on the west (-x) side
411    ///
412    /// Saturates at the western edge of the grid (x = 0).
413    #[must_use]
414    #[expect(
415        clippy::arithmetic_side_effects,
416        reason = "GridCoordinates + GridCoordinateOffset saturates at u32::MIN/u32::MAX"
417    )]
418    pub fn expanded_west(&self, by: u16) -> Self {
419        Self::new(
420            self.lower_left_corner.to_owned() + GridCoordinateOffset::new(-i32::from(by), 0),
421            self.upper_right_corner.to_owned(),
422        )
423    }
424
425    /// returns a new `GridRectangle` extended by `by` regions on the east (+x) side
426    ///
427    /// Saturates at the eastern edge of the grid (x = `u32::MAX`).
428    #[must_use]
429    #[expect(
430        clippy::arithmetic_side_effects,
431        reason = "GridCoordinates + GridCoordinateOffset saturates at u32::MIN/u32::MAX"
432    )]
433    pub fn expanded_east(&self, by: u16) -> Self {
434        Self::new(
435            self.lower_left_corner.to_owned(),
436            self.upper_right_corner.to_owned() + GridCoordinateOffset::new(i32::from(by), 0),
437        )
438    }
439
440    /// returns a new `GridRectangle` extended by `by` regions on the south (-y) side
441    ///
442    /// Saturates at the southern edge of the grid (y = 0).
443    #[must_use]
444    #[expect(
445        clippy::arithmetic_side_effects,
446        reason = "GridCoordinates + GridCoordinateOffset saturates at u32::MIN/u32::MAX"
447    )]
448    pub fn expanded_south(&self, by: u16) -> Self {
449        Self::new(
450            self.lower_left_corner.to_owned() + GridCoordinateOffset::new(0, -i32::from(by)),
451            self.upper_right_corner.to_owned(),
452        )
453    }
454
455    /// returns a new `GridRectangle` extended by `by` regions on the north (+y) side
456    ///
457    /// Saturates at the northern edge of the grid (y = `u32::MAX`).
458    #[must_use]
459    #[expect(
460        clippy::arithmetic_side_effects,
461        reason = "GridCoordinates + GridCoordinateOffset saturates at u32::MIN/u32::MAX"
462    )]
463    pub fn expanded_north(&self, by: u16) -> Self {
464        Self::new(
465            self.lower_left_corner.to_owned(),
466            self.upper_right_corner.to_owned() + GridCoordinateOffset::new(0, i32::from(by)),
467        )
468    }
469}
470
471/// represents a grid rectangle like type (usually one that contains a
472/// grid rectangle or one that contains a corner and is of a known size
473pub trait GridRectangleLike {
474    /// the `GridRectangle` represented by this map like image
475    #[must_use]
476    fn grid_rectangle(&self) -> GridRectangle;
477
478    /// returns the lower left corner of the rectangle
479    #[must_use]
480    fn lower_left_corner(&self) -> GridCoordinates {
481        self.grid_rectangle().lower_left_corner().to_owned()
482    }
483
484    /// returns the lower right corner of the rectangle
485    #[must_use]
486    fn lower_right_corner(&self) -> GridCoordinates {
487        GridCoordinates::new(
488            self.grid_rectangle().upper_right_corner().x(),
489            self.grid_rectangle().lower_left_corner().y(),
490        )
491    }
492
493    /// returns the upper left corner of the rectangle
494    #[must_use]
495    fn upper_left_corner(&self) -> GridCoordinates {
496        GridCoordinates::new(
497            self.grid_rectangle().lower_left_corner().x(),
498            self.grid_rectangle().upper_right_corner().y(),
499        )
500    }
501
502    /// returns the upper right corner of the rectangle
503    #[must_use]
504    fn upper_right_corner(&self) -> GridCoordinates {
505        self.grid_rectangle().upper_right_corner().to_owned()
506    }
507
508    /// the size of the map like image in regions in the x direction (width)
509    #[must_use]
510    fn size_x(&self) -> u32 {
511        self.grid_rectangle().size_x()
512    }
513
514    /// the size of the map like image in regions in the y direction (width)
515    #[must_use]
516    fn size_y(&self) -> u32 {
517        self.grid_rectangle().size_y()
518    }
519
520    /// returns a range for the region x coordinates of this rectangle
521    #[must_use]
522    fn x_range(&self) -> std::ops::RangeInclusive<u32> {
523        self.lower_left_corner().x()..=self.upper_right_corner().x()
524    }
525
526    /// returns a range for the region y coordinates of this rectangle
527    #[must_use]
528    fn y_range(&self) -> std::ops::RangeInclusive<u32> {
529        self.lower_left_corner().y()..=self.upper_right_corner().y()
530    }
531
532    /// checks if a given set of `GridCoordinates` is within this `GridRectangle`
533    #[must_use]
534    fn contains(&self, grid_coordinates: &GridCoordinates) -> bool {
535        self.lower_left_corner().x() <= grid_coordinates.x()
536            && grid_coordinates.x() <= self.upper_right_corner().x()
537            && self.lower_left_corner().y() <= grid_coordinates.y()
538            && grid_coordinates.y() <= self.upper_right_corner().y()
539    }
540
541    /// returns a new `GridRectangle` which is the area where this `GridRectangle`
542    /// and another intersect each other or None if there is no intersection
543    #[must_use]
544    fn intersect<O>(&self, other: &O) -> Option<GridRectangle>
545    where
546        O: GridRectangleLike,
547    {
548        let self_x_range: ranges::GenericRange<u32> = self.x_range().into();
549        let self_y_range: ranges::GenericRange<u32> = self.y_range().into();
550        let other_x_range: ranges::GenericRange<u32> = other.x_range().into();
551        let other_y_range: ranges::GenericRange<u32> = other.y_range().into();
552        let x_intersection = self_x_range.intersect(other_x_range);
553        let y_intersection = self_y_range.intersect(other_y_range);
554        match (x_intersection, y_intersection) {
555            (
556                ranges::OperationResult::Single(x_range),
557                ranges::OperationResult::Single(y_range),
558            ) => {
559                use std::ops::Bound;
560                use std::ops::RangeBounds as _;
561                match (
562                    x_range.start_bound(),
563                    x_range.end_bound(),
564                    y_range.start_bound(),
565                    y_range.end_bound(),
566                ) {
567                    (
568                        Bound::Included(start_x),
569                        Bound::Included(end_x),
570                        Bound::Included(start_y),
571                        Bound::Included(end_y),
572                    ) => Some(GridRectangle::new(
573                        GridCoordinates::new(*start_x, *start_y),
574                        GridCoordinates::new(*end_x, *end_y),
575                    )),
576                    _ => None,
577                }
578            }
579            _ => None,
580        }
581    }
582
583    /// returns a PPS HUD description string for this `GridRectangle`
584    ///
585    /// The PPS HUD is a map HUD commonly used in the SL sailing community
586    /// and usually you need to configure it by clicking on the HUD while
587    /// you are at the matching location in-world to calibrate the coordinates
588    /// on the map texture.
589    ///
590    /// This string needs to be put in the description of the PPS HUD
591    /// dot prim with "Edit linked objects" to avoid the need for manual
592    /// calibration.
593    #[must_use]
594    fn pps_hud_config(&self) -> String {
595        // the lower left corner as an LSL vector of metres from the grid
596        // coordinate origin (`<256 * grid_x, 256 * grid_y, 0>`)
597        let lower_left_corner = GlobalCoordinates::from_grid_corner(self.lower_left_corner());
598        // this is the lower left corner as an LSL vector of meters from the grid coordinate origin
599        // followed by the width and height of the map in regions
600        // and a 0/1 for the locked state of the HUD
601        // each of those is separated from the next by a slash character
602        format!(
603            "<{},{},0>/{}/{}/1",
604            lower_left_corner.x(),
605            lower_left_corner.y(),
606            f64::from(self.size_x()),
607            f64::from(self.size_y())
608        )
609    }
610}
611
612impl GridRectangleLike for GridRectangle {
613    fn grid_rectangle(&self) -> GridRectangle {
614        self.to_owned()
615    }
616
617    fn lower_left_corner(&self) -> GridCoordinates {
618        self.lower_left_corner.to_owned()
619    }
620
621    fn upper_right_corner(&self) -> GridCoordinates {
622        self.upper_right_corner.to_owned()
623    }
624
625    fn size_x(&self) -> u32 {
626        self.upper_right_corner
627            .x()
628            .saturating_sub(self.lower_left_corner().x())
629            .saturating_add(1)
630    }
631
632    fn size_y(&self) -> u32 {
633        self.upper_right_corner
634            .y()
635            .saturating_sub(self.lower_left_corner().y())
636            .saturating_add(1)
637    }
638
639    fn x_range(&self) -> std::ops::RangeInclusive<u32> {
640        self.lower_left_corner.x()..=self.upper_right_corner.x()
641    }
642
643    fn y_range(&self) -> std::ops::RangeInclusive<u32> {
644        self.lower_left_corner.y()..=self.upper_right_corner.y()
645    }
646}
647
648impl GridRectangleLike for MapTileDescriptor {
649    fn grid_rectangle(&self) -> GridRectangle {
650        GridRectangle::new(
651            self.lower_left_corner,
652            GridCoordinates::new(
653                self.lower_left_corner
654                    .x()
655                    .saturating_add(u32::from(self.zoom_level.tile_size()))
656                    .saturating_sub(1),
657                self.lower_left_corner
658                    .y()
659                    .saturating_add(u32::from(self.zoom_level.tile_size()))
660                    .saturating_sub(1),
661            ),
662        )
663    }
664}
665
666/// A trait to allow adding methods to `Vec<GridCoordinates>`
667pub trait GridCoordinatesExt {
668    /// returns the coordinates of the lower left corner and the coordinates of
669    /// the upper right corner of a rectangle of regions containing all the grid
670    /// coordinates in this container
671    ///
672    /// returns None if the container is empty
673    fn bounding_rectangle(&self) -> Option<GridRectangle>;
674}
675
676impl GridCoordinatesExt for Vec<GridCoordinates> {
677    fn bounding_rectangle(&self) -> Option<GridRectangle> {
678        if self.is_empty() {
679            return None;
680        }
681        let (xs, ys): (Vec<u32>, Vec<u32>) = self.iter().map(|gc| (gc.x(), gc.y())).unzip();
682        // unwrap is okay in these cases because we checked above that the container is non-empty
683        #[expect(
684            clippy::unwrap_used,
685            reason = "we checked above that the container is non-empty"
686        )]
687        let (min_x, max_x) = (xs.iter().min().unwrap(), xs.iter().max().unwrap());
688        #[expect(
689            clippy::unwrap_used,
690            reason = "we checked above that the container is non-empty"
691        )]
692        let (min_y, max_y) = (ys.iter().min().unwrap(), ys.iter().max().unwrap());
693        Some(GridRectangle {
694            lower_left_corner: GridCoordinates::new(*min_x, *min_y),
695            upper_right_corner: GridCoordinates::new(*max_x, *max_y),
696        })
697    }
698}
699
700/// Region coordinates for the position of something inside a region
701///
702/// Usually limited to 0..256 for x and y and 0..4096 for z (height)
703/// but values outside those ranges are possible for positions of objects
704/// in the process of crossing from one region to another or in similar
705/// situations where they belong to one simulator logically but are located
706/// outside of that simulator's region
707#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
708pub struct RegionCoordinates {
709    /// the x coordinate inside the region from the western edge (0) to the
710    /// eastern edge (256)
711    x: f32,
712    /// the y coordinate inside the region from the southern edge (0) to the
713    /// northern edge (256)
714    y: f32,
715    /// the z coordinate inside the region from the bottom (0) to the top (4096)
716    /// higher values are possible but for objects can not be rezzed above 4096m
717    /// and teleports are clamped to that as well
718    z: f32,
719}
720
721/// parse region coordinates
722///
723/// "{ 1.234, 2.345, 3.456 }"
724///
725/// # Errors
726///
727/// returns an error if the string could not be parsed
728#[cfg(feature = "chumsky")]
729#[must_use]
730pub fn region_coordinates_parser<'src>()
731-> impl Parser<'src, &'src str, RegionCoordinates, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
732{
733    just('{')
734        .ignore_then(whitespace().or_not())
735        .ignore_then(f32_parser())
736        .then_ignore(just(','))
737        .then_ignore(whitespace().or_not())
738        .then(f32_parser())
739        .then_ignore(just(','))
740        .then_ignore(whitespace().or_not())
741        .then(f32_parser())
742        .then_ignore(whitespace().or_not())
743        .then_ignore(just('}'))
744        .map(|((x, y), z)| RegionCoordinates::new(x, y, z))
745}
746
747impl RegionCoordinates {
748    /// Create a new `RegionCoordinates`
749    #[must_use]
750    pub const fn new(x: f32, y: f32, z: f32) -> Self {
751        Self { x, y, z }
752    }
753
754    /// The x coordinate inside the region
755    #[must_use]
756    pub const fn x(&self) -> f32 {
757        self.x
758    }
759
760    /// The y coordinate inside the region
761    #[must_use]
762    pub const fn y(&self) -> f32 {
763        self.y
764    }
765
766    /// The z coordinate inside the region
767    #[must_use]
768    pub const fn z(&self) -> f32 {
769        self.z
770    }
771
772    /// checks if the coordinates are within bounds
773    #[must_use]
774    pub fn in_bounds(&self) -> bool {
775        self.x >= 0f32
776            && self.x < 256f32
777            && self.y >= 0f32
778            && self.y < 256f32
779            && self.z >= 0f32
780            && self.z < 4096f32
781    }
782}
783
784impl From<crate::lsl::Vector> for RegionCoordinates {
785    fn from(value: crate::lsl::Vector) -> Self {
786        Self {
787            x: value.x,
788            y: value.y,
789            z: value.z,
790        }
791    }
792}
793
794/// The number of metres along one axis of a region; a grid-index step.
795const REGION_SIZE_METERS: f64 = 256.0;
796
797/// A 3-D facing direction — the direction an avatar faces, as carried by the
798/// various `look_at` fields (the viewer's agent/camera *at*-axis). It is a
799/// direction, **not** a position: the wire stores three `f32`s and the viewer
800/// uses the full 3-D vector (including any vertical component) as the forward
801/// axis. It is conventionally a unit vector, but the wire does not enforce
802/// normalisation, so the raw components are preserved verbatim for byte-identical
803/// round-trips.
804#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
805pub struct Direction {
806    /// The x component of the facing direction.
807    x: f32,
808    /// The y component of the facing direction.
809    y: f32,
810    /// The z component of the facing direction.
811    z: f32,
812}
813
814impl Direction {
815    /// A zero direction (the wire `(0, 0, 0)` sentinel the viewer replaces with
816    /// the current camera axis).
817    pub const ZERO: Self = Self {
818        x: 0.0,
819        y: 0.0,
820        z: 0.0,
821    };
822
823    /// Creates a direction from its raw components, without normalising.
824    #[must_use]
825    pub const fn new(x: f32, y: f32, z: f32) -> Self {
826        Self { x, y, z }
827    }
828
829    /// The x component of the facing direction.
830    #[must_use]
831    pub const fn x(&self) -> f32 {
832        self.x
833    }
834
835    /// The y component of the facing direction.
836    #[must_use]
837    pub const fn y(&self) -> f32 {
838        self.y
839    }
840
841    /// The z component of the facing direction.
842    #[must_use]
843    pub const fn z(&self) -> f32 {
844        self.z
845    }
846
847    /// The Euclidean length (magnitude) of the direction vector.
848    #[must_use]
849    pub fn length(&self) -> f32 {
850        self.z
851            .mul_add(self.z, self.x.mul_add(self.x, self.y * self.y))
852            .sqrt()
853    }
854
855    /// The unit-length direction, or `None` when the vector has (near-)zero
856    /// length and a direction is therefore undefined.
857    #[must_use]
858    pub fn normalized(&self) -> Option<Self> {
859        let length = self.length();
860        if length > f32::EPSILON {
861            Some(Self::new(self.x / length, self.y / length, self.z / length))
862        } else {
863            None
864        }
865    }
866}
867
868/// A grid-global position in metres — the viewer's `LLVector3d` "global" frame,
869/// where the value along an axis is `region_grid_index * 256 + region_local`.
870/// Held as `f64` to match the wire's double-precision global vectors (the
871/// directory/event/pick replies carry `LLVector3d`); the few replies that send
872/// a single-precision global position widen to `f64` at the codec boundary.
873///
874/// `sl-types` also has region-local ([`RegionCoordinates`]) and region-index
875/// ([`GridCoordinates`]) coordinates; this is the global-metre coordinate that
876/// relates the two.
877#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
878pub struct GlobalCoordinates {
879    /// The global x coordinate, in metres (west→east).
880    x: f64,
881    /// The global y coordinate, in metres (south→north).
882    y: f64,
883    /// The global z coordinate, in metres (altitude).
884    z: f64,
885}
886
887impl GlobalCoordinates {
888    /// Creates global coordinates from their raw metre components.
889    #[must_use]
890    pub const fn new(x: f64, y: f64, z: f64) -> Self {
891        Self { x, y, z }
892    }
893
894    /// The global x coordinate, in metres.
895    #[must_use]
896    pub const fn x(&self) -> f64 {
897        self.x
898    }
899
900    /// The global y coordinate, in metres.
901    #[must_use]
902    pub const fn y(&self) -> f64 {
903        self.y
904    }
905
906    /// The global z coordinate, in metres.
907    #[must_use]
908    pub const fn z(&self) -> f64 {
909        self.z
910    }
911
912    /// Combines a region's grid index and a region-local position into a global
913    /// position (`grid_index * 256 + region_local`). The inverse of
914    /// [`split`](Self::split).
915    #[must_use]
916    pub fn from_grid_and_region(grid: GridCoordinates, region: RegionCoordinates) -> Self {
917        Self {
918            x: f64::from(grid.x()).mul_add(REGION_SIZE_METERS, f64::from(region.x())),
919            y: f64::from(grid.y()).mul_add(REGION_SIZE_METERS, f64::from(region.y())),
920            z: f64::from(region.z()),
921        }
922    }
923
924    /// The grid-global position of a region's south-west **corner** — its
925    /// `grid_index * 256` origin at zero altitude. This is the corner the PPS
926    /// HUD config uses (`<256 * grid_x, 256 * grid_y, 0>`); it avoids
927    /// constructing a throwaway all-zero [`RegionCoordinates`] just to reach
928    /// [`from_grid_and_region`](Self::from_grid_and_region).
929    #[must_use]
930    pub fn from_grid_corner(grid: GridCoordinates) -> Self {
931        Self {
932            x: f64::from(grid.x()) * REGION_SIZE_METERS,
933            y: f64::from(grid.y()) * REGION_SIZE_METERS,
934            z: 0.0,
935        }
936    }
937
938    /// Splits a global position into the containing region's grid index and the
939    /// region-local position within it. The inverse of
940    /// [`from_grid_and_region`](Self::from_grid_and_region).
941    ///
942    /// Returns `None` when the global position falls outside the representable
943    /// grid (a negative or out-of-`u32`-range region index), which never
944    /// happens for a position the grid actually sent.
945    #[must_use]
946    pub fn split(&self) -> Option<(GridCoordinates, RegionCoordinates)> {
947        let grid_x = region_index(self.x)?;
948        let grid_y = region_index(self.y)?;
949        let local_x = f64::from(grid_x).mul_add(-REGION_SIZE_METERS, self.x);
950        let local_y = f64::from(grid_y).mul_add(-REGION_SIZE_METERS, self.y);
951        Some((
952            GridCoordinates::new(grid_x, grid_y),
953            RegionCoordinates::new(narrow(local_x), narrow(local_y), narrow(self.z)),
954        ))
955    }
956}
957
958impl From<(GridCoordinates, RegionCoordinates)> for GlobalCoordinates {
959    fn from((grid, region): (GridCoordinates, RegionCoordinates)) -> Self {
960        Self::from_grid_and_region(grid, region)
961    }
962}
963
964impl From<GridCoordinates> for GlobalCoordinates {
965    /// Builds the south-west corner of the region (see
966    /// [`from_grid_corner`](Self::from_grid_corner)).
967    fn from(grid: GridCoordinates) -> Self {
968        Self::from_grid_corner(grid)
969    }
970}
971
972/// The region grid index containing a global-metre coordinate, or `None` when
973/// it falls outside the `0..=u32::MAX` grid range (including a non-finite or
974/// negative input).
975#[expect(
976    clippy::as_conversions,
977    clippy::cast_possible_truncation,
978    clippy::cast_sign_loss,
979    reason = "the floored index is checked finite and within u32 range before the cast"
980)]
981fn region_index(meters: f64) -> Option<u32> {
982    let index = (meters / REGION_SIZE_METERS).floor();
983    if index.is_finite() && index >= 0.0 && index <= f64::from(u32::MAX) {
984        Some(index as u32)
985    } else {
986        None
987    }
988}
989
990/// Narrows a region-local-metre `f64` to the `f32` a region-local coordinate
991/// uses. A region-local offset is a small (0..256) in-range metre value, so the
992/// narrowing is exact for the values the grid sends.
993#[expect(
994    clippy::as_conversions,
995    clippy::cast_possible_truncation,
996    reason = "a region-local offset is a small (0..256) in-range metre value"
997)]
998const fn narrow(meters: f64) -> f32 {
999    meters as f32
1000}
1001
1002/// A 3-axis **scale factor** (X/Y/Z) — a dimensionless multiplier per axis,
1003/// **not** a size in metres. Its one wire user is the water normal-map
1004/// "Reflection Wavelet Scale" (the viewer's `normal_scale`: three per-axis
1005/// multipliers applied to the wavelet normal-map sampling). A scale is not a
1006/// position or a direction (it has no origin and need not be a unit vector), so
1007/// it gets its own type.
1008#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1009pub struct Scale {
1010    /// The x-axis scale factor.
1011    x: f32,
1012    /// The y-axis scale factor.
1013    y: f32,
1014    /// The z-axis scale factor.
1015    z: f32,
1016}
1017
1018impl Scale {
1019    /// Creates a scale from its per-axis factors.
1020    #[must_use]
1021    pub const fn new(x: f32, y: f32, z: f32) -> Self {
1022        Self { x, y, z }
1023    }
1024
1025    /// The x-axis scale factor.
1026    #[must_use]
1027    pub const fn x(&self) -> f32 {
1028        self.x
1029    }
1030
1031    /// The y-axis scale factor.
1032    #[must_use]
1033    pub const fn y(&self) -> f32 {
1034        self.y
1035    }
1036
1037    /// The z-axis scale factor.
1038    #[must_use]
1039    pub const fn z(&self) -> f32 {
1040        self.z
1041    }
1042}
1043
1044/// The flags describing how and why a teleport happened, carried by
1045/// `TeleportFinish` (and `TeleportProgress`) as the `TeleportFlags` U32
1046/// bitfield. Mirrors the reference viewer's `TELEPORT_FLAGS_*`
1047/// (`indra/llmessage/llteleportflags.h`).
1048///
1049/// Note: OpenSim collapses the flags it sends on `TeleportFinish` to
1050/// [`VIA_LOCATION`](Self::VIA_LOCATION) (plus [`IS_FLYING`](Self::IS_FLYING)),
1051/// so the full set of `VIA_*` reasons is only observable on the Second Life
1052/// grid.
1053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1054pub struct TeleportFlags(pub u32);
1055
1056impl TeleportFlags {
1057    /// Set the agent's home to the teleport target (`SET_HOME_TO_TARGET`, a
1058    /// newbie leaving the prelude).
1059    pub const SET_HOME_TO_TARGET: u32 = 1 << 0;
1060    /// Set the agent's last location to the target (`SET_LAST_TO_TARGET`).
1061    pub const SET_LAST_TO_TARGET: u32 = 1 << 1;
1062    /// Teleport via a lure / teleport offer (`VIA_LURE`).
1063    pub const VIA_LURE: u32 = 1 << 2;
1064    /// Teleport via a landmark (`VIA_LANDMARK`).
1065    pub const VIA_LANDMARK: u32 = 1 << 3;
1066    /// Teleport via an explicit location (`VIA_LOCATION`).
1067    pub const VIA_LOCATION: u32 = 1 << 4;
1068    /// Teleport to the agent's home (`VIA_HOME`).
1069    pub const VIA_HOME: u32 = 1 << 5;
1070    /// Teleport via a telehub (`VIA_TELEHUB`).
1071    pub const VIA_TELEHUB: u32 = 1 << 6;
1072    /// Teleport as part of logging in (`VIA_LOGIN`).
1073    pub const VIA_LOGIN: u32 = 1 << 7;
1074    /// Teleport via a godlike lure (`VIA_GODLIKE_LURE`).
1075    pub const VIA_GODLIKE_LURE: u32 = 1 << 8;
1076    /// The teleport was performed with god powers (`GODLIKE`).
1077    pub const GODLIKE: u32 = 1 << 9;
1078    /// An emergency ("911") teleport (`FLAGS_911`).
1079    pub const NINE_ONE_ONE: u32 = 1 << 10;
1080    /// Cancelling the teleport is disabled (`DISABLE_CANCEL`, used by
1081    /// `llTeleportAgentHome`).
1082    pub const DISABLE_CANCEL: u32 = 1 << 11;
1083    /// Teleport via a region id (`VIA_REGION_ID`).
1084    pub const VIA_REGION_ID: u32 = 1 << 12;
1085    /// The agent was flying when the teleport started (`IS_FLYING`).
1086    pub const IS_FLYING: u32 = 1 << 13;
1087    /// Show the reset-home UI on arrival (`SHOW_RESET_HOME`).
1088    pub const SHOW_RESET_HOME: u32 = 1 << 14;
1089    /// Force a redirect to some location (`FORCE_REDIRECT`, used when kicking
1090    /// someone from land).
1091    pub const FORCE_REDIRECT: u32 = 1 << 15;
1092    /// Teleport via global coordinates (`VIA_GLOBAL_COORDS`).
1093    pub const VIA_GLOBAL_COORDS: u32 = 1 << 16;
1094    /// The teleport stays within the same region (`WITHIN_REGION`).
1095    pub const WITHIN_REGION: u32 = 1 << 17;
1096
1097    /// Whether all of the bits in `mask` are set.
1098    #[must_use]
1099    pub const fn contains(self, mask: u32) -> bool {
1100        self.0 & mask == mask
1101    }
1102}
1103
1104/// The name of a region
1105#[nutype::nutype(
1106    sanitize(trim),
1107    validate(len_char_min = 2, len_char_max = 35),
1108    derive(
1109        Debug,
1110        Clone,
1111        Display,
1112        Hash,
1113        PartialEq,
1114        Eq,
1115        PartialOrd,
1116        Ord,
1117        Serialize,
1118        Deserialize,
1119        AsRef
1120    )
1121)]
1122pub struct RegionName(String);
1123
1124/// parse an url encoded string into a RegionName
1125///
1126/// # Errors
1127///
1128/// returns an error if the string could not be parsed
1129#[cfg(feature = "chumsky")]
1130#[must_use]
1131pub fn url_region_name_parser<'src>()
1132-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
1133    url_text_component_parser().try_map(|region_name, span| {
1134        RegionName::try_new(&region_name).map_err(|err| {
1135            chumsky::error::Rich::custom(
1136                span,
1137                format!("failed to parse url-encoded region name ({region_name}): {err:?}"),
1138            )
1139        })
1140    })
1141}
1142
1143/// parse a string into a RegionName
1144///
1145/// # Errors
1146///
1147/// returns an error if the string could not be parsed
1148#[cfg(feature = "chumsky")]
1149#[must_use]
1150pub fn region_name_parser<'src>()
1151-> impl Parser<'src, &'src str, RegionName, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
1152    any()
1153        .filter(|c: &char| {
1154            c.is_alphabetic() || c.is_numeric() || *c == ' ' || *c == '\'' || *c == '-'
1155        })
1156        .repeated()
1157        .at_least(2)
1158        .collect::<String>()
1159        .try_map(|region_name, span| {
1160            RegionName::try_new(&region_name).map_err(|err| {
1161                chumsky::error::Rich::custom(
1162                    span,
1163                    format!("failed to parse region name ({region_name}): {err:?}"),
1164                )
1165            })
1166        })
1167}
1168
1169/// A location inside Second Life the way it is usually represented in
1170/// SLURLs or map URLs, based on a Region Name and integer coordinates
1171/// inside the region
1172#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1173pub struct Location {
1174    /// the name of the region of the location
1175    pub region_name: RegionName,
1176    /// the x coordinate inside the region
1177    pub x: u8,
1178    /// the y coordinate inside the region
1179    pub y: u8,
1180    /// the z coordinate inside the region
1181    pub z: u16,
1182}
1183
1184/// parse a string into a Location where no component is url-encoded
1185///
1186/// # Errors
1187///
1188/// returns an error if the string could not be parsed
1189#[cfg(feature = "chumsky")]
1190#[must_use]
1191pub fn location_parser<'src>()
1192-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
1193    region_name_parser()
1194        .then_ignore(just('/'))
1195        .then(u8_parser())
1196        .then_ignore(just('/'))
1197        .then(u8_parser())
1198        .then_ignore(just('/'))
1199        .then(u16_parser())
1200        .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
1201}
1202
1203/// parse a string into a Location where the region name is url encoded
1204/// but each component of the location is separated by an actual slash
1205///
1206/// # Errors
1207///
1208/// returns an error if the string could not be parsed
1209#[cfg(feature = "chumsky")]
1210#[must_use]
1211pub fn url_location_parser<'src>()
1212-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
1213    url_region_name_parser()
1214        .then_ignore(just('/'))
1215        .then(u8_parser())
1216        .then_ignore(just('/'))
1217        .then(u8_parser())
1218        .then_ignore(just('/'))
1219        .then(u16_parser())
1220        .map(|(((region_name, x), y), z)| Location::new(region_name, x, y, z))
1221}
1222
1223/// parse a string into a Location from a URL-encoded location (the slashes in
1224/// particular)
1225///
1226/// # Errors
1227///
1228/// returns an error if the string could not be parsed
1229#[cfg(feature = "chumsky")]
1230#[must_use]
1231pub fn url_encoded_location_parser<'src>()
1232-> impl Parser<'src, &'src str, Location, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
1233    url_text_component_parser().try_map(|s, span| {
1234        location_parser().parse(&s).into_result().map_err(|err| {
1235            chumsky::error::Rich::custom(
1236                span,
1237                format!("Parsing {s} as location failed with: {err:#?}"),
1238            )
1239        })
1240    })
1241}
1242
1243/// the possible errors that can occur when parsing a String to a `Location`
1244#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, strum::EnumIs)]
1245pub enum LocationParseError {
1246    /// unexpected number of /-separated components in the location URL
1247    #[error(
1248        "unexpected number of /-separated components in the location URL {0}, found {1} expected 4 (for a bare location) or 8 (for a URL)"
1249    )]
1250    UnexpectedComponentCount(String, usize),
1251    /// unexpected scheme in the location URL
1252    #[error("unexpected scheme in the location URL {0}, found {1}, expected http: or https:")]
1253    UnexpectedScheme(String, String),
1254    /// unexpected non-empty second component in location URL
1255    #[error(
1256        "unexpected non-empty second component in location URL {0}, found {1}, expected http or https"
1257    )]
1258    UnexpectedNonEmptySecondComponent(String, String),
1259    /// unexpected host in the location URL
1260    #[error(
1261        "unexpected host in the location URL {0}, found {1}, expected maps.secondlife.com or slurl.com"
1262    )]
1263    UnexpectedHost(String, String),
1264    /// unexpected path in the location URL
1265    #[error("unexpected path in the location URL {0}, found {1}, expected secondlife")]
1266    UnexpectedPath(String, String),
1267    /// error parsing the region name
1268    #[error("error parsing the region name {0}: {1}")]
1269    RegionName(String, RegionNameError),
1270    /// error parsing the X coordinate
1271    #[error("error parsing the X coordinate {0}: {1}")]
1272    X(String, std::num::ParseIntError),
1273    /// error parsing the Y coordinate
1274    #[error("error parsing the Y coordinate {0}: {1}")]
1275    Y(String, std::num::ParseIntError),
1276    /// error parsing the Z coordinate
1277    #[error("error parsing the Z coordinate {0}: {1}")]
1278    Z(String, std::num::ParseIntError),
1279}
1280
1281impl std::str::FromStr for Location {
1282    type Err = LocationParseError;
1283
1284    fn from_str(s: &str) -> Result<Self, Self::Err> {
1285        // if the string is an USB-notecard line drop everything after the first comma
1286        let usb_location = s
1287            .split_once(',')
1288            .map_or(s, |(usb_location, _usb_comment)| usb_location);
1289        let parts = usb_location.split('/').collect::<Vec<_>>();
1290        if let [region_name, x, y, z] = parts.as_slice() {
1291            let region_name = RegionName::try_new(region_name.replace("%20", " "))
1292                .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
1293            let x = x
1294                .parse()
1295                .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
1296            let y = y
1297                .parse()
1298                .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
1299            let z = z
1300                .parse()
1301                .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
1302            return Ok(Self {
1303                region_name,
1304                x,
1305                y,
1306                z,
1307            });
1308        }
1309        if let [scheme, second_component, host, path, region_name, x, y, z] = parts.as_slice() {
1310            if *scheme != "http:" && *scheme != "https:" {
1311                return Err(LocationParseError::UnexpectedScheme(
1312                    s.to_owned(),
1313                    scheme.to_string(),
1314                ));
1315            }
1316            if !second_component.is_empty() {
1317                return Err(LocationParseError::UnexpectedNonEmptySecondComponent(
1318                    s.to_owned(),
1319                    second_component.to_string(),
1320                ));
1321            }
1322            if *host != "maps.secondlife.com" && *host != "slurl.com" {
1323                return Err(LocationParseError::UnexpectedHost(
1324                    s.to_owned(),
1325                    host.to_string(),
1326                ));
1327            }
1328            if *path != "secondlife" {
1329                return Err(LocationParseError::UnexpectedPath(
1330                    s.to_owned(),
1331                    path.to_string(),
1332                ));
1333            }
1334            let region_name = RegionName::try_new(region_name.replace("%20", " "))
1335                .map_err(|err| LocationParseError::RegionName(s.to_owned(), err))?;
1336            let x = x
1337                .parse()
1338                .map_err(|err| LocationParseError::X(s.to_owned(), err))?;
1339            let y = y
1340                .parse()
1341                .map_err(|err| LocationParseError::Y(s.to_owned(), err))?;
1342            let z = z
1343                .parse()
1344                .map_err(|err| LocationParseError::Z(s.to_owned(), err))?;
1345            return Ok(Self {
1346                region_name,
1347                x,
1348                y,
1349                z,
1350            });
1351        }
1352        Err(LocationParseError::UnexpectedComponentCount(
1353            s.to_owned(),
1354            parts.len(),
1355        ))
1356    }
1357}
1358
1359impl Location {
1360    /// Creates a new `Location`
1361    #[must_use]
1362    pub const fn new(region_name: RegionName, x: u8, y: u8, z: u16) -> Self {
1363        Self {
1364            region_name,
1365            x,
1366            y,
1367            z,
1368        }
1369    }
1370
1371    /// The region name of this `Location`
1372    #[must_use]
1373    pub const fn region_name(&self) -> &RegionName {
1374        &self.region_name
1375    }
1376
1377    /// The x coordinate of the `Location`
1378    #[must_use]
1379    pub const fn x(&self) -> u8 {
1380        self.x
1381    }
1382
1383    /// The y coordinate of the `Location`
1384    #[must_use]
1385    pub const fn y(&self) -> u8 {
1386        self.y
1387    }
1388
1389    /// The z coordinate of the `Location`
1390    #[must_use]
1391    pub const fn z(&self) -> u16 {
1392        self.z
1393    }
1394
1395    /// returns a maps.secondlife.com URL for the `Location`
1396    #[must_use]
1397    pub fn as_maps_url(&self) -> String {
1398        format!(
1399            "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
1400            self.region_name, self.x, self.y, self.z
1401        )
1402    }
1403}
1404
1405/// A location inside Second Life the way it is usually represented in
1406/// SLURLs or map URLs, based on a Region Name and integer coordinates
1407/// inside the region, this variant allows out of bounds coordinates
1408/// (negative and 256 or above for x and y and negative for z)
1409#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1410pub struct UnconstrainedLocation {
1411    /// the name of the region of the location
1412    pub region_name: RegionName,
1413    /// the x coordinate inside the region
1414    pub x: i16,
1415    /// the y coordinate inside the region
1416    pub y: i16,
1417    /// the z coordinate inside the region
1418    pub z: i32,
1419}
1420
1421impl UnconstrainedLocation {
1422    /// Creates a new `UnconstrainedLocation`
1423    #[must_use]
1424    pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
1425        Self {
1426            region_name,
1427            x,
1428            y,
1429            z,
1430        }
1431    }
1432
1433    /// The region name of this `UnconstrainedLocation`
1434    #[must_use]
1435    pub const fn region_name(&self) -> &RegionName {
1436        &self.region_name
1437    }
1438
1439    /// The x coordinate of the `UnconstrainedLocation`
1440    #[must_use]
1441    pub const fn x(&self) -> i16 {
1442        self.x
1443    }
1444
1445    /// The y coordinate of the `UnconstrainedLocation`
1446    #[must_use]
1447    pub const fn y(&self) -> i16 {
1448        self.y
1449    }
1450
1451    /// The z coordinate of the `UnconstrainedLocation`
1452    #[must_use]
1453    pub const fn z(&self) -> i32 {
1454        self.z
1455    }
1456}
1457
1458/// parse a string into an UnconstrainedLocation where nothing is urlencoded
1459///
1460/// # Errors
1461///
1462/// returns an error if the string could not be parsed
1463#[cfg(feature = "chumsky")]
1464#[must_use]
1465pub fn unconstrained_location_parser<'src>() -> impl Parser<
1466    'src,
1467    &'src str,
1468    UnconstrainedLocation,
1469    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1470> {
1471    region_name_parser()
1472        .then_ignore(just('/'))
1473        .then(i16_parser())
1474        .then_ignore(just('/'))
1475        .then(i16_parser())
1476        .then_ignore(just('/'))
1477        .then(i32_parser())
1478        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1479}
1480
1481/// parse a string into an UnconstrainedLocation where the region is urlencoded
1482/// but the components are separated by actual slashes
1483///
1484/// # Errors
1485///
1486/// returns an error if the string could not be parsed
1487#[cfg(feature = "chumsky")]
1488#[must_use]
1489pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1490    'src,
1491    &'src str,
1492    UnconstrainedLocation,
1493    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1494> {
1495    url_region_name_parser()
1496        .then_ignore(just('/'))
1497        .then(i16_parser())
1498        .then_ignore(just('/'))
1499        .then(i16_parser())
1500        .then_ignore(just('/'))
1501        .then(i32_parser())
1502        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1503}
1504
1505/// parse a string into an UnconstrainedLocation where the entire location is
1506/// urlencoded with urlencoded slashes
1507///
1508/// # Errors
1509///
1510/// returns an error if the string could not be parsed
1511#[cfg(feature = "chumsky")]
1512#[must_use]
1513pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1514    'src,
1515    &'src str,
1516    UnconstrainedLocation,
1517    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1518> {
1519    url_region_name_parser()
1520        .then_ignore(just('/'))
1521        .then(i16_parser())
1522        .then_ignore(just('/'))
1523        .then(i16_parser())
1524        .then_ignore(just('/'))
1525        .then(i32_parser())
1526        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1527}
1528
1529impl TryFrom<UnconstrainedLocation> for Location {
1530    type Error = std::num::TryFromIntError;
1531
1532    fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1533        Ok(Self::new(
1534            value.region_name,
1535            value.x.try_into()?,
1536            value.y.try_into()?,
1537            value.z.try_into()?,
1538        ))
1539    }
1540}
1541
1542impl From<Location> for UnconstrainedLocation {
1543    fn from(value: Location) -> Self {
1544        Self {
1545            region_name: value.region_name,
1546            x: value.x.into(),
1547            y: value.y.into(),
1548            z: value.z.into(),
1549        }
1550    }
1551}
1552
1553/// The map tile zoom level for the Second Life main map
1554#[nutype::nutype(
1555    validate(greater_or_equal = 1, less_or_equal = 8),
1556    derive(
1557        Debug,
1558        Clone,
1559        Copy,
1560        Display,
1561        FromStr,
1562        Hash,
1563        PartialEq,
1564        Eq,
1565        PartialOrd,
1566        Ord,
1567        Serialize,
1568        Deserialize
1569    )
1570)]
1571pub struct ZoomLevel(u8);
1572
1573/// Errors that can occur when trying to find the correct zoom level to fit
1574/// regions into an output image of a given size
1575#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1576pub enum ZoomFitError {
1577    /// The region size in the x direction can not be zero
1578    #[error("region size in x direction can not be zero")]
1579    RegionSizeXZero,
1580
1581    /// The region size in the y direction can not be zero
1582    #[error("region size in y direction can not be zero")]
1583    RegionSizeYZero,
1584
1585    /// The output image size in the x direction can not be zero
1586    #[error("output image size in x direction can not be zero")]
1587    OutputSizeXZero,
1588
1589    /// The output image size in the y direction can not be zero
1590    #[error("output image size in y direction can not be zero")]
1591    OutputSizeYZero,
1592
1593    /// Error converting a logarithm value into a `u8` (should never happen)
1594    #[error("error converting a logarithm value into a u8")]
1595    LogarithmConversionError(#[from] std::num::TryFromIntError),
1596
1597    /// Error creating the zoom level from the calculated value
1598    /// (should never happen)
1599    #[error("error creating zoom level from calculated value")]
1600    ZoomLevelError(#[from] ZoomLevelError),
1601}
1602
1603impl ZoomLevel {
1604    /// returns the map tile size in number of regions at this zoom level
1605    ///
1606    /// This applies to both dimensions equally since both regions and map tiles
1607    /// are square
1608    #[must_use]
1609    pub fn tile_size(&self) -> u16 {
1610        let exponent: u32 = self.into_inner().into();
1611        let exponent = exponent.saturating_sub(1);
1612        2u16.pow(exponent)
1613    }
1614
1615    /// returns the map tile size in pixels at this zoom level
1616    ///
1617    /// This applies to both dimensions equally since both regions and map tiles
1618    /// are square
1619    #[expect(
1620        clippy::arithmetic_side_effects,
1621        reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1622    )]
1623    #[must_use]
1624    pub fn tile_size_in_pixels(&self) -> u32 {
1625        let tile_size: u32 = self.tile_size().into();
1626        let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1627        tile_size * region_size_in_map_tile_in_pixels
1628    }
1629
1630    /// returns the lower left (lowest coordinate for each axis) coordinate of
1631    /// the map tile containing the given grid coordinates at this zoom level
1632    ///
1633    /// That is the coordinates used for the file name of the map tile at this
1634    /// zoom level that contains the region (or gap where a region could be)
1635    /// given by the grid coordinates
1636    #[must_use]
1637    pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1638        let tile_size = u32::from(self.tile_size());
1639        #[expect(
1640            clippy::arithmetic_side_effects,
1641            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)"
1642        )]
1643        GridCoordinates {
1644            x: x.saturating_sub(x % tile_size),
1645            y: y.saturating_sub(y % tile_size),
1646        }
1647    }
1648
1649    /// returns the size of a region in pixels in a map tile of this zoom level
1650    ///
1651    /// The size applies to both dimensions equally since both regions and map tiles
1652    /// are square
1653    #[must_use]
1654    pub fn pixels_per_region(&self) -> u16 {
1655        let exponent: u32 = self.into_inner().into();
1656        let exponent = exponent.saturating_sub(1);
1657        let exponent = 8u32.saturating_sub(exponent);
1658        2u16.pow(exponent)
1659    }
1660
1661    /// returns the number of pixels per meter at this zoom level
1662    #[must_use]
1663    pub fn pixels_per_meter(&self) -> f32 {
1664        f32::from(self.pixels_per_region()) / 256f32
1665    }
1666
1667    /// returns the zoom level that is the highest zoom level that makes sense
1668    /// to use if we want to fit a given area of regions into a given image size
1669    /// assuming we want to always have one map tile pixel on one output pixel
1670    ///
1671    /// # Errors
1672    ///
1673    /// returns an error if any of the parameters are zero or in the (theoretically
1674    /// impossible if the algorithm is correct) case that ZoomLevel::try_new()
1675    /// returns an error on the calculated value
1676    pub fn max_zoom_level_to_fit_regions_into_output_image(
1677        region_x: u32,
1678        region_y: u32,
1679        output_x: u32,
1680        output_y: u32,
1681    ) -> Result<Self, ZoomFitError> {
1682        if region_x == 0 {
1683            return Err(ZoomFitError::RegionSizeXZero);
1684        }
1685        if region_y == 0 {
1686            return Err(ZoomFitError::RegionSizeYZero);
1687        }
1688        if output_x == 0 {
1689            return Err(ZoomFitError::OutputSizeXZero);
1690        }
1691        if output_y == 0 {
1692            return Err(ZoomFitError::OutputSizeYZero);
1693        }
1694        let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x);
1695        let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y);
1696        let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1697            8,
1698            output_pixels_per_region_x
1699                .ilog2()
1700                .try_into()
1701                .map_err(ZoomFitError::LogarithmConversionError)?,
1702        ));
1703        let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1704            8,
1705            output_pixels_per_region_y
1706                .ilog2()
1707                .try_into()
1708                .map_err(ZoomFitError::LogarithmConversionError)?,
1709        ));
1710        Ok(Self::try_new(std::cmp::max(
1711            max_zoom_level_x,
1712            max_zoom_level_y,
1713        ))?)
1714    }
1715}
1716
1717/// describes a map tile
1718#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1719#[expect(
1720    clippy::module_name_repetitions,
1721    reason = "the type is used outside this module"
1722)]
1723pub struct MapTileDescriptor {
1724    /// the zoom level of the map tile
1725    zoom_level: ZoomLevel,
1726    /// the lower left corner of the map tile
1727    lower_left_corner: GridCoordinates,
1728}
1729
1730impl MapTileDescriptor {
1731    /// create a new `MapTileDescriptor`
1732    ///
1733    /// this will automatically normalize the given `GridCoordinates` to the
1734    /// lower left corner of a map tile at that zoom level
1735    #[must_use]
1736    pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1737        let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1738        Self {
1739            zoom_level,
1740            lower_left_corner,
1741        }
1742    }
1743
1744    /// the `ZoomLevel` of the map tile
1745    #[must_use]
1746    pub const fn zoom_level(&self) -> &ZoomLevel {
1747        &self.zoom_level
1748    }
1749
1750    /// the `GridCoordinates` of the lower left corner of this map tile
1751    #[must_use]
1752    pub const fn lower_left_corner(&self) -> &GridCoordinates {
1753        &self.lower_left_corner
1754    }
1755
1756    /// the size of this map tile in regions
1757    #[must_use]
1758    pub fn tile_size(&self) -> u16 {
1759        self.zoom_level.tile_size()
1760    }
1761
1762    /// the size of this map tile in pixels
1763    #[must_use]
1764    pub fn tile_size_in_pixels(&self) -> u32 {
1765        self.zoom_level.tile_size_in_pixels()
1766    }
1767
1768    /// the grid rectangle covered by this map tile
1769    #[must_use]
1770    pub fn grid_rectangle(&self) -> GridRectangle {
1771        GridRectangle::new(
1772            self.lower_left_corner,
1773            GridCoordinates::new(
1774                self.lower_left_corner
1775                    .x()
1776                    .saturating_add(u32::from(self.zoom_level.tile_size()))
1777                    .saturating_sub(1),
1778                self.lower_left_corner
1779                    .y()
1780                    .saturating_add(u32::from(self.zoom_level.tile_size()))
1781                    .saturating_sub(1),
1782            ),
1783        )
1784    }
1785}
1786
1787/// A waypoint in the Universal Sailor Buddy (USB) notecard format
1788#[derive(Debug, Clone)]
1789pub struct USBWaypoint {
1790    /// the location of the waypoint
1791    location: Location,
1792    /// the comment for the waypoint if any
1793    comment: Option<String>,
1794}
1795
1796impl USBWaypoint {
1797    /// Create a new USB waypoint
1798    #[must_use]
1799    pub const fn new(location: Location, comment: Option<String>) -> Self {
1800        Self { location, comment }
1801    }
1802
1803    /// get the location of the waypoint
1804    #[must_use]
1805    pub const fn location(&self) -> &Location {
1806        &self.location
1807    }
1808
1809    /// get the region coordinates of the waypoint
1810    #[must_use]
1811    pub fn region_coordinates(&self) -> RegionCoordinates {
1812        RegionCoordinates::new(
1813            f32::from(self.location.x()),
1814            f32::from(self.location.y()),
1815            f32::from(self.location.z()),
1816        )
1817    }
1818
1819    /// get the comment for the waypoint if any
1820    #[must_use]
1821    pub const fn comment(&self) -> Option<&String> {
1822        self.comment.as_ref()
1823    }
1824}
1825
1826impl std::fmt::Display for USBWaypoint {
1827    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1828        write!(f, "{}", self.location.as_maps_url())?;
1829        if let Some(comment) = &self.comment {
1830            write!(f, ",{comment}")?;
1831        }
1832        Ok(())
1833    }
1834}
1835
1836impl std::str::FromStr for USBWaypoint {
1837    type Err = LocationParseError;
1838
1839    fn from_str(s: &str) -> Result<Self, Self::Err> {
1840        if let Some((location, comment)) = s.split_once(',') {
1841            Ok(Self {
1842                location: location.parse()?,
1843                comment: Some(comment.to_owned()),
1844            })
1845        } else {
1846            Ok(Self {
1847                location: s.parse()?,
1848                comment: None,
1849            })
1850        }
1851    }
1852}
1853
1854/// An Universal Sailor Buddy (USB) notecard
1855#[derive(Debug, Clone)]
1856pub struct USBNotecard {
1857    /// the waypoints in the notecard
1858    waypoints: Vec<USBWaypoint>,
1859}
1860
1861/// Errors that can happen when an USB notecard is read from a file
1862#[derive(Debug, thiserror::Error, strum::EnumIs)]
1863pub enum USBNotecardLoadError {
1864    /// I/O errors opening or reading the file
1865    #[error("I/O error opening or reading the file: {0}")]
1866    Io(#[from] std::io::Error),
1867    /// Parse error deserializing the USB notecard lines
1868    #[error("parse error deserializing the USB notecard lines: {0}")]
1869    LocationParseError(#[from] LocationParseError),
1870}
1871
1872impl USBNotecard {
1873    /// Create a new USB notecard
1874    #[must_use]
1875    pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1876        Self { waypoints }
1877    }
1878
1879    /// get the waypoints in the notecard
1880    #[must_use]
1881    pub fn waypoints(&self) -> &[USBWaypoint] {
1882        &self.waypoints
1883    }
1884
1885    /// load an USB Notecard from a text file
1886    ///
1887    /// # Errors
1888    ///
1889    /// this returns an error if either reading the file or parsing the content
1890    /// as a `USBNotecard` fail
1891    pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1892        let contents = std::fs::read_to_string(filename)?;
1893        Ok(contents.parse()?)
1894    }
1895}
1896
1897impl std::fmt::Display for USBNotecard {
1898    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1899        for waypoint in &self.waypoints {
1900            writeln!(f, "{waypoint}")?;
1901        }
1902        Ok(())
1903    }
1904}
1905
1906impl std::str::FromStr for USBNotecard {
1907    type Err = LocationParseError;
1908
1909    fn from_str(s: &str) -> Result<Self, Self::Err> {
1910        s.lines()
1911            .map(|line| line.parse::<USBWaypoint>())
1912            .collect::<Result<Vec<_>, _>>()
1913            .map(|waypoints| Self { waypoints })
1914    }
1915}
1916
1917#[cfg(test)]
1918mod test {
1919    use super::*;
1920    use pretty_assertions::assert_eq;
1921
1922    #[test]
1923    fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1924        assert_eq!(
1925            "Beach%20Valley/110/67/24".parse::<Location>(),
1926            Ok(Location {
1927                region_name: RegionName::try_new("Beach Valley")?,
1928                x: 110,
1929                y: 67,
1930                z: 24
1931            }),
1932        );
1933        Ok(())
1934    }
1935
1936    #[test]
1937    fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1938        assert_eq!(
1939            "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1940            Ok(Location {
1941                region_name: RegionName::try_new("Beach Valley")?,
1942                x: 110,
1943                y: 67,
1944                z: 24
1945            }),
1946        );
1947        Ok(())
1948    }
1949
1950    #[test]
1951    fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1952        assert_eq!(
1953            "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1954            Ok(Location {
1955                region_name: RegionName::try_new("Beach Valley")?,
1956                x: 110,
1957                y: 67,
1958                z: 24
1959            }),
1960        );
1961        Ok(())
1962    }
1963
1964    #[test]
1965    fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
1966        assert_eq!(
1967            "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
1968            Ok(Location {
1969                region_name: RegionName::try_new("Beach Valley")?,
1970                x: 110,
1971                y: 67,
1972                z: 24
1973            }),
1974        );
1975        Ok(())
1976    }
1977
1978    #[test]
1979    fn test_grid_rectangle_intersection_upper_right_corner()
1980    -> Result<(), Box<dyn std::error::Error>> {
1981        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1982        let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
1983        assert_eq!(
1984            rect1.intersect(&rect2),
1985            Some(GridRectangle::new(
1986                GridCoordinates::new(15, 15),
1987                GridCoordinates::new(20, 20),
1988            ))
1989        );
1990        Ok(())
1991    }
1992
1993    #[test]
1994    fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
1995    {
1996        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
1997        let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
1998        assert_eq!(
1999            rect1.intersect(&rect2),
2000            Some(GridRectangle::new(
2001                GridCoordinates::new(10, 15),
2002                GridCoordinates::new(15, 20),
2003            ))
2004        );
2005        Ok(())
2006    }
2007
2008    #[test]
2009    fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
2010    {
2011        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2012        let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
2013        assert_eq!(
2014            rect1.intersect(&rect2),
2015            Some(GridRectangle::new(
2016                GridCoordinates::new(10, 10),
2017                GridCoordinates::new(15, 15),
2018            ))
2019        );
2020        Ok(())
2021    }
2022
2023    #[test]
2024    fn test_grid_rectangle_intersection_lower_right_corner()
2025    -> Result<(), Box<dyn std::error::Error>> {
2026        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2027        let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
2028        assert_eq!(
2029            rect1.intersect(&rect2),
2030            Some(GridRectangle::new(
2031                GridCoordinates::new(15, 10),
2032                GridCoordinates::new(20, 15),
2033            ))
2034        );
2035        Ok(())
2036    }
2037
2038    #[test]
2039    fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
2040        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2041        let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
2042        assert_eq!(rect1.intersect(&rect2), None);
2043        Ok(())
2044    }
2045
2046    #[test]
2047    fn test_grid_rectangle_expanded_west() {
2048        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2049        assert_eq!(rect.expanded_west(0), rect);
2050        assert_eq!(
2051            rect.expanded_west(3),
2052            GridRectangle::new(GridCoordinates::new(7, 10), GridCoordinates::new(20, 20)),
2053        );
2054        let near_edge =
2055            GridRectangle::new(GridCoordinates::new(2, 10), GridCoordinates::new(20, 20));
2056        assert_eq!(
2057            near_edge.expanded_west(5),
2058            GridRectangle::new(GridCoordinates::new(0, 10), GridCoordinates::new(20, 20)),
2059        );
2060    }
2061
2062    #[test]
2063    fn test_grid_rectangle_expanded_east() {
2064        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2065        assert_eq!(rect.expanded_east(0), rect);
2066        assert_eq!(
2067            rect.expanded_east(3),
2068            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(23, 20)),
2069        );
2070        let near_edge = GridRectangle::new(
2071            GridCoordinates::new(10, 10),
2072            GridCoordinates::new(u32::MAX - 2, 20),
2073        );
2074        assert_eq!(
2075            near_edge.expanded_east(5),
2076            GridRectangle::new(
2077                GridCoordinates::new(10, 10),
2078                GridCoordinates::new(u32::MAX, 20),
2079            ),
2080        );
2081    }
2082
2083    #[test]
2084    fn test_grid_rectangle_expanded_south() {
2085        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2086        assert_eq!(rect.expanded_south(0), rect);
2087        assert_eq!(
2088            rect.expanded_south(3),
2089            GridRectangle::new(GridCoordinates::new(10, 7), GridCoordinates::new(20, 20)),
2090        );
2091        let near_edge =
2092            GridRectangle::new(GridCoordinates::new(10, 2), GridCoordinates::new(20, 20));
2093        assert_eq!(
2094            near_edge.expanded_south(5),
2095            GridRectangle::new(GridCoordinates::new(10, 0), GridCoordinates::new(20, 20)),
2096        );
2097    }
2098
2099    #[test]
2100    fn test_grid_rectangle_expanded_north() {
2101        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2102        assert_eq!(rect.expanded_north(0), rect);
2103        assert_eq!(
2104            rect.expanded_north(3),
2105            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 23)),
2106        );
2107        let near_edge = GridRectangle::new(
2108            GridCoordinates::new(10, 10),
2109            GridCoordinates::new(20, u32::MAX - 2),
2110        );
2111        assert_eq!(
2112            near_edge.expanded_north(5),
2113            GridRectangle::new(
2114                GridCoordinates::new(10, 10),
2115                GridCoordinates::new(20, u32::MAX),
2116            ),
2117        );
2118    }
2119
2120    #[cfg(feature = "chumsky")]
2121    #[test]
2122    fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2123        let region_name = "Viterbo";
2124        assert_eq!(
2125            url_region_name_parser().parse(region_name).into_result(),
2126            Ok(RegionName::try_new(region_name)?)
2127        );
2128        Ok(())
2129    }
2130
2131    #[cfg(feature = "chumsky")]
2132    #[test]
2133    fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2134        let region_name = "Da Boom";
2135        let input = region_name.replace(' ', "%20");
2136        assert_eq!(
2137            url_region_name_parser().parse(&input).into_result(),
2138            Ok(RegionName::try_new(region_name)?)
2139        );
2140        Ok(())
2141    }
2142
2143    #[cfg(feature = "chumsky")]
2144    #[test]
2145    fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2146        let region_name = "Da Boom";
2147        assert_eq!(
2148            region_name_parser().parse(region_name).into_result(),
2149            Ok(RegionName::try_new(region_name)?)
2150        );
2151        Ok(())
2152    }
2153
2154    #[cfg(feature = "chumsky")]
2155    #[test]
2156    fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2157        let region_name = "Viterbo";
2158        let input = format!("{region_name}/1/2/300");
2159        assert_eq!(
2160            url_location_parser().parse(&input).into_result(),
2161            Ok(Location {
2162                region_name: RegionName::try_new(region_name)?,
2163                x: 1,
2164                y: 2,
2165                z: 300
2166            })
2167        );
2168        Ok(())
2169    }
2170
2171    #[cfg(feature = "chumsky")]
2172    #[test]
2173    fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2174        let region_name = "Da Boom";
2175        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
2176        assert_eq!(
2177            url_location_parser().parse(&input).into_result(),
2178            Ok(Location {
2179                region_name: RegionName::try_new(region_name)?,
2180                x: 1,
2181                y: 2,
2182                z: 300
2183            })
2184        );
2185        Ok(())
2186    }
2187
2188    #[cfg(feature = "chumsky")]
2189    #[test]
2190    fn test_url_location_parser_url_whitespace_single_digit_after_space()
2191    -> Result<(), Box<dyn std::error::Error>> {
2192        let region_name = "Foo Bar 3";
2193        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
2194        assert_eq!(
2195            url_location_parser().parse(&input).into_result(),
2196            Ok(Location {
2197                region_name: RegionName::try_new(region_name)?,
2198                x: 1,
2199                y: 2,
2200                z: 300
2201            })
2202        );
2203        Ok(())
2204    }
2205
2206    #[cfg(feature = "chumsky")]
2207    #[test]
2208    fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
2209        assert_eq!(
2210            region_coordinates_parser()
2211                .parse("{ 63.0486, 45.2515, 1501.08 }")
2212                .into_result(),
2213            Ok(RegionCoordinates {
2214                x: 63.0486,
2215                y: 45.2515,
2216                z: 1501.08,
2217            })
2218        );
2219        Ok(())
2220    }
2221}