Skip to main content

sl_types/
map.rs

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