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    ///
1422    /// spaces in the region name are percent-encoded (`%20`), the form the
1423    /// map site serves and [`str::parse`] on a `Location` decodes; the other
1424    /// region-name characters (alphanumerics, apostrophe, hyphen) are
1425    /// URL-safe as-is
1426    #[must_use]
1427    pub fn as_maps_url(&self) -> String {
1428        format!(
1429            "https://maps.secondlife.com/secondlife/{}/{}/{}/{}",
1430            self.region_name.to_string().replace(' ', "%20"),
1431            self.x,
1432            self.y,
1433            self.z
1434        )
1435    }
1436}
1437
1438/// A location inside Second Life the way it is usually represented in
1439/// SLURLs or map URLs, based on a Region Name and integer coordinates
1440/// inside the region, this variant allows out of bounds coordinates
1441/// (negative and 256 or above for x and y and negative for z)
1442#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1443pub struct UnconstrainedLocation {
1444    /// the name of the region of the location
1445    pub region_name: RegionName,
1446    /// the x coordinate inside the region
1447    pub x: i16,
1448    /// the y coordinate inside the region
1449    pub y: i16,
1450    /// the z coordinate inside the region
1451    pub z: i32,
1452}
1453
1454impl UnconstrainedLocation {
1455    /// Creates a new `UnconstrainedLocation`
1456    #[must_use]
1457    pub const fn new(region_name: RegionName, x: i16, y: i16, z: i32) -> Self {
1458        Self {
1459            region_name,
1460            x,
1461            y,
1462            z,
1463        }
1464    }
1465
1466    /// The region name of this `UnconstrainedLocation`
1467    #[must_use]
1468    pub const fn region_name(&self) -> &RegionName {
1469        &self.region_name
1470    }
1471
1472    /// The x coordinate of the `UnconstrainedLocation`
1473    #[must_use]
1474    pub const fn x(&self) -> i16 {
1475        self.x
1476    }
1477
1478    /// The y coordinate of the `UnconstrainedLocation`
1479    #[must_use]
1480    pub const fn y(&self) -> i16 {
1481        self.y
1482    }
1483
1484    /// The z coordinate of the `UnconstrainedLocation`
1485    #[must_use]
1486    pub const fn z(&self) -> i32 {
1487        self.z
1488    }
1489}
1490
1491/// parse a string into an UnconstrainedLocation where nothing is urlencoded
1492///
1493/// # Errors
1494///
1495/// returns an error if the string could not be parsed
1496#[cfg(feature = "chumsky")]
1497#[must_use]
1498pub fn unconstrained_location_parser<'src>() -> impl Parser<
1499    'src,
1500    &'src str,
1501    UnconstrainedLocation,
1502    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1503> {
1504    region_name_parser()
1505        .then_ignore(just('/'))
1506        .then(i16_parser())
1507        .then_ignore(just('/'))
1508        .then(i16_parser())
1509        .then_ignore(just('/'))
1510        .then(i32_parser())
1511        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1512}
1513
1514/// parse a string into an UnconstrainedLocation where the region is urlencoded
1515/// but the components are separated by actual slashes
1516///
1517/// # Errors
1518///
1519/// returns an error if the string could not be parsed
1520#[cfg(feature = "chumsky")]
1521#[must_use]
1522pub fn url_unconstrained_location_parser<'src>() -> impl Parser<
1523    'src,
1524    &'src str,
1525    UnconstrainedLocation,
1526    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1527> {
1528    url_region_name_parser()
1529        .then_ignore(just('/'))
1530        .then(i16_parser())
1531        .then_ignore(just('/'))
1532        .then(i16_parser())
1533        .then_ignore(just('/'))
1534        .then(i32_parser())
1535        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1536}
1537
1538/// parse a string into an UnconstrainedLocation where the entire location is
1539/// urlencoded with urlencoded slashes
1540///
1541/// # Errors
1542///
1543/// returns an error if the string could not be parsed
1544#[cfg(feature = "chumsky")]
1545#[must_use]
1546pub fn urlencoded_unconstrained_location_parser<'src>() -> impl Parser<
1547    'src,
1548    &'src str,
1549    UnconstrainedLocation,
1550    chumsky::extra::Err<chumsky::error::Rich<'src, char>>,
1551> {
1552    url_region_name_parser()
1553        .then_ignore(just('/'))
1554        .then(i16_parser())
1555        .then_ignore(just('/'))
1556        .then(i16_parser())
1557        .then_ignore(just('/'))
1558        .then(i32_parser())
1559        .map(|(((region_name, x), y), z)| UnconstrainedLocation::new(region_name, x, y, z))
1560}
1561
1562impl TryFrom<UnconstrainedLocation> for Location {
1563    type Error = std::num::TryFromIntError;
1564
1565    fn try_from(value: UnconstrainedLocation) -> Result<Self, Self::Error> {
1566        Ok(Self::new(
1567            value.region_name,
1568            value.x.try_into()?,
1569            value.y.try_into()?,
1570            value.z.try_into()?,
1571        ))
1572    }
1573}
1574
1575impl From<Location> for UnconstrainedLocation {
1576    fn from(value: Location) -> Self {
1577        Self {
1578            region_name: value.region_name,
1579            x: value.x.into(),
1580            y: value.y.into(),
1581            z: value.z.into(),
1582        }
1583    }
1584}
1585
1586/// The map tile zoom level for the Second Life main map
1587#[nutype::nutype(
1588    validate(greater_or_equal = 1, less_or_equal = 8),
1589    derive(
1590        Debug,
1591        Clone,
1592        Copy,
1593        Display,
1594        FromStr,
1595        Hash,
1596        PartialEq,
1597        Eq,
1598        PartialOrd,
1599        Ord,
1600        Serialize,
1601        Deserialize
1602    )
1603)]
1604pub struct ZoomLevel(u8);
1605
1606/// Errors that can occur when trying to find the correct zoom level to fit
1607/// regions into an output image of a given size
1608#[derive(Debug, Clone, thiserror::Error, strum::EnumIs)]
1609pub enum ZoomFitError {
1610    /// The region size in the x direction can not be zero
1611    #[error("region size in x direction can not be zero")]
1612    RegionSizeXZero,
1613
1614    /// The region size in the y direction can not be zero
1615    #[error("region size in y direction can not be zero")]
1616    RegionSizeYZero,
1617
1618    /// The output image size in the x direction can not be zero
1619    #[error("output image size in x direction can not be zero")]
1620    OutputSizeXZero,
1621
1622    /// The output image size in the y direction can not be zero
1623    #[error("output image size in y direction can not be zero")]
1624    OutputSizeYZero,
1625
1626    /// Error converting a logarithm value into a `u8` (should never happen)
1627    #[error("error converting a logarithm value into a u8")]
1628    LogarithmConversionError(#[from] std::num::TryFromIntError),
1629
1630    /// Error creating the zoom level from the calculated value
1631    /// (should never happen)
1632    #[error("error creating zoom level from calculated value")]
1633    ZoomLevelError(#[from] ZoomLevelError),
1634}
1635
1636impl ZoomLevel {
1637    /// returns the map tile size in number of regions at this zoom level
1638    ///
1639    /// This applies to both dimensions equally since both regions and map tiles
1640    /// are square
1641    #[must_use]
1642    pub fn tile_size(&self) -> u16 {
1643        let exponent: u32 = self.into_inner().into();
1644        let exponent = exponent.saturating_sub(1);
1645        2u16.pow(exponent)
1646    }
1647
1648    /// returns the map tile size in pixels at this zoom level
1649    ///
1650    /// This applies to both dimensions equally since both regions and map tiles
1651    /// are square
1652    #[expect(
1653        clippy::arithmetic_side_effects,
1654        reason = "both values we multiply here are u16 originally so their product should never overflow an u32"
1655    )]
1656    #[must_use]
1657    pub fn tile_size_in_pixels(&self) -> u32 {
1658        let tile_size: u32 = self.tile_size().into();
1659        let region_size_in_map_tile_in_pixels: u32 = self.pixels_per_region().into();
1660        tile_size * region_size_in_map_tile_in_pixels
1661    }
1662
1663    /// returns the lower left (lowest coordinate for each axis) coordinate of
1664    /// the map tile containing the given grid coordinates at this zoom level
1665    ///
1666    /// That is the coordinates used for the file name of the map tile at this
1667    /// zoom level that contains the region (or gap where a region could be)
1668    /// given by the grid coordinates
1669    #[must_use]
1670    pub fn map_tile_corner(&self, GridCoordinates { x, y }: &GridCoordinates) -> GridCoordinates {
1671        let tile_size = u32::from(self.tile_size());
1672        #[expect(
1673            clippy::arithmetic_side_effects,
1674            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)"
1675        )]
1676        GridCoordinates {
1677            x: x.saturating_sub(x % tile_size),
1678            y: y.saturating_sub(y % tile_size),
1679        }
1680    }
1681
1682    /// returns the size of a region in pixels in a map tile of this zoom level
1683    ///
1684    /// The size applies to both dimensions equally since both regions and map tiles
1685    /// are square
1686    #[must_use]
1687    pub fn pixels_per_region(&self) -> u16 {
1688        let exponent: u32 = self.into_inner().into();
1689        let exponent = exponent.saturating_sub(1);
1690        let exponent = 8u32.saturating_sub(exponent);
1691        2u16.pow(exponent)
1692    }
1693
1694    /// returns the number of pixels per meter at this zoom level
1695    #[must_use]
1696    pub fn pixels_per_meter(&self) -> f32 {
1697        f32::from(self.pixels_per_region()) / 256f32
1698    }
1699
1700    /// returns the zoom level that is the highest zoom level that makes sense
1701    /// to use if we want to fit a given area of regions into a given image size
1702    /// assuming we want to always have one map tile pixel on one output pixel
1703    ///
1704    /// # Errors
1705    ///
1706    /// returns an error if any of the parameters are zero or in the (theoretically
1707    /// impossible if the algorithm is correct) case that ZoomLevel::try_new()
1708    /// returns an error on the calculated value
1709    pub fn max_zoom_level_to_fit_regions_into_output_image(
1710        region_x: u32,
1711        region_y: u32,
1712        output_x: u32,
1713        output_y: u32,
1714    ) -> Result<Self, ZoomFitError> {
1715        if region_x == 0 {
1716            return Err(ZoomFitError::RegionSizeXZero);
1717        }
1718        if region_y == 0 {
1719            return Err(ZoomFitError::RegionSizeYZero);
1720        }
1721        if output_x == 0 {
1722            return Err(ZoomFitError::OutputSizeXZero);
1723        }
1724        if output_y == 0 {
1725            return Err(ZoomFitError::OutputSizeYZero);
1726        }
1727        let output_pixels_per_region_x: u32 = output_x.div_ceil(region_x);
1728        let output_pixels_per_region_y: u32 = output_y.div_ceil(region_y);
1729        let max_zoom_level_x: u8 = 9u8.saturating_sub(std::cmp::min(
1730            8,
1731            output_pixels_per_region_x
1732                .ilog2()
1733                .try_into()
1734                .map_err(ZoomFitError::LogarithmConversionError)?,
1735        ));
1736        let max_zoom_level_y: u8 = 9u8.saturating_sub(std::cmp::min(
1737            8,
1738            output_pixels_per_region_y
1739                .ilog2()
1740                .try_into()
1741                .map_err(ZoomFitError::LogarithmConversionError)?,
1742        ));
1743        Ok(Self::try_new(std::cmp::max(
1744            max_zoom_level_x,
1745            max_zoom_level_y,
1746        ))?)
1747    }
1748}
1749
1750/// describes a map tile
1751#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1752#[expect(
1753    clippy::module_name_repetitions,
1754    reason = "the type is used outside this module"
1755)]
1756pub struct MapTileDescriptor {
1757    /// the zoom level of the map tile
1758    zoom_level: ZoomLevel,
1759    /// the lower left corner of the map tile
1760    lower_left_corner: GridCoordinates,
1761}
1762
1763impl MapTileDescriptor {
1764    /// create a new `MapTileDescriptor`
1765    ///
1766    /// this will automatically normalize the given `GridCoordinates` to the
1767    /// lower left corner of a map tile at that zoom level
1768    #[must_use]
1769    pub fn new(zoom_level: ZoomLevel, grid_coordinates: GridCoordinates) -> Self {
1770        let lower_left_corner = zoom_level.map_tile_corner(&grid_coordinates);
1771        Self {
1772            zoom_level,
1773            lower_left_corner,
1774        }
1775    }
1776
1777    /// the `ZoomLevel` of the map tile
1778    #[must_use]
1779    pub const fn zoom_level(&self) -> &ZoomLevel {
1780        &self.zoom_level
1781    }
1782
1783    /// the `GridCoordinates` of the lower left corner of this map tile
1784    #[must_use]
1785    pub const fn lower_left_corner(&self) -> &GridCoordinates {
1786        &self.lower_left_corner
1787    }
1788
1789    /// the size of this map tile in regions
1790    #[must_use]
1791    pub fn tile_size(&self) -> u16 {
1792        self.zoom_level.tile_size()
1793    }
1794
1795    /// the size of this map tile in pixels
1796    #[must_use]
1797    pub fn tile_size_in_pixels(&self) -> u32 {
1798        self.zoom_level.tile_size_in_pixels()
1799    }
1800
1801    /// the grid rectangle covered by this map tile
1802    #[must_use]
1803    pub fn grid_rectangle(&self) -> GridRectangle {
1804        GridRectangle::new(
1805            self.lower_left_corner,
1806            GridCoordinates::new(
1807                self.lower_left_corner
1808                    .x()
1809                    .saturating_add(u32::from(self.zoom_level.tile_size()))
1810                    .saturating_sub(1),
1811                self.lower_left_corner
1812                    .y()
1813                    .saturating_add(u32::from(self.zoom_level.tile_size()))
1814                    .saturating_sub(1),
1815            ),
1816        )
1817    }
1818}
1819
1820/// A waypoint in the Universal Sailor Buddy (USB) notecard format
1821#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1822pub struct USBWaypoint {
1823    /// the location of the waypoint
1824    location: Location,
1825    /// the comment for the waypoint if any
1826    comment: Option<String>,
1827}
1828
1829impl USBWaypoint {
1830    /// Create a new USB waypoint
1831    #[must_use]
1832    pub const fn new(location: Location, comment: Option<String>) -> Self {
1833        Self { location, comment }
1834    }
1835
1836    /// get the location of the waypoint
1837    #[must_use]
1838    pub const fn location(&self) -> &Location {
1839        &self.location
1840    }
1841
1842    /// get the region coordinates of the waypoint
1843    #[must_use]
1844    pub fn region_coordinates(&self) -> RegionCoordinates {
1845        RegionCoordinates::new(
1846            f32::from(self.location.x()),
1847            f32::from(self.location.y()),
1848            f32::from(self.location.z()),
1849        )
1850    }
1851
1852    /// get the comment for the waypoint if any
1853    #[must_use]
1854    pub const fn comment(&self) -> Option<&String> {
1855        self.comment.as_ref()
1856    }
1857}
1858
1859impl std::fmt::Display for USBWaypoint {
1860    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1861        write!(f, "{}", self.location.as_maps_url())?;
1862        if let Some(comment) = &self.comment {
1863            write!(f, ",{comment}")?;
1864        }
1865        Ok(())
1866    }
1867}
1868
1869impl std::str::FromStr for USBWaypoint {
1870    type Err = LocationParseError;
1871
1872    fn from_str(s: &str) -> Result<Self, Self::Err> {
1873        if let Some((location, comment)) = s.split_once(',') {
1874            Ok(Self {
1875                location: location.parse()?,
1876                comment: Some(comment.to_owned()),
1877            })
1878        } else {
1879            Ok(Self {
1880                location: s.parse()?,
1881                comment: None,
1882            })
1883        }
1884    }
1885}
1886
1887/// An Universal Sailor Buddy (USB) notecard
1888#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1889pub struct USBNotecard {
1890    /// the waypoints in the notecard
1891    waypoints: Vec<USBWaypoint>,
1892}
1893
1894/// Errors that can happen when an USB notecard is read from a file
1895#[derive(Debug, thiserror::Error, strum::EnumIs)]
1896pub enum USBNotecardLoadError {
1897    /// I/O errors opening or reading the file
1898    #[error("I/O error opening or reading the file: {0}")]
1899    Io(#[from] std::io::Error),
1900    /// Parse error deserializing the USB notecard lines
1901    #[error("parse error deserializing the USB notecard lines: {0}")]
1902    LocationParseError(#[from] LocationParseError),
1903}
1904
1905impl USBNotecard {
1906    /// Create a new USB notecard
1907    #[must_use]
1908    pub const fn new(waypoints: Vec<USBWaypoint>) -> Self {
1909        Self { waypoints }
1910    }
1911
1912    /// get the waypoints in the notecard
1913    #[must_use]
1914    pub fn waypoints(&self) -> &[USBWaypoint] {
1915        &self.waypoints
1916    }
1917
1918    /// load an USB Notecard from a text file
1919    ///
1920    /// # Errors
1921    ///
1922    /// this returns an error if either reading the file or parsing the content
1923    /// as a `USBNotecard` fail
1924    pub fn load_from_file(filename: &std::path::Path) -> Result<Self, USBNotecardLoadError> {
1925        let contents = std::fs::read_to_string(filename)?;
1926        Ok(contents.parse()?)
1927    }
1928}
1929
1930impl std::fmt::Display for USBNotecard {
1931    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1932        for waypoint in &self.waypoints {
1933            writeln!(f, "{waypoint}")?;
1934        }
1935        Ok(())
1936    }
1937}
1938
1939impl std::str::FromStr for USBNotecard {
1940    type Err = LocationParseError;
1941
1942    fn from_str(s: &str) -> Result<Self, Self::Err> {
1943        s.lines()
1944            .map(|line| line.parse::<USBWaypoint>())
1945            .collect::<Result<Vec<_>, _>>()
1946            .map(|waypoints| Self { waypoints })
1947    }
1948}
1949
1950#[cfg(test)]
1951mod test {
1952    use super::*;
1953    use pretty_assertions::assert_eq;
1954
1955    #[test]
1956    fn test_parse_location_bare() -> Result<(), Box<dyn std::error::Error>> {
1957        assert_eq!(
1958            "Beach%20Valley/110/67/24".parse::<Location>(),
1959            Ok(Location {
1960                region_name: RegionName::try_new("Beach Valley")?,
1961                x: 110,
1962                y: 67,
1963                z: 24
1964            }),
1965        );
1966        Ok(())
1967    }
1968
1969    #[test]
1970    fn test_parse_location_url_maps() -> Result<(), Box<dyn std::error::Error>> {
1971        assert_eq!(
1972            "http://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
1973            Ok(Location {
1974                region_name: RegionName::try_new("Beach Valley")?,
1975                x: 110,
1976                y: 67,
1977                z: 24
1978            }),
1979        );
1980        Ok(())
1981    }
1982
1983    #[test]
1984    fn test_as_maps_url_percent_encodes_spaces() -> Result<(), Box<dyn std::error::Error>> {
1985        let location = Location::new(RegionName::try_new("Beach Valley")?, 110, 67, 24);
1986        let url = location.as_maps_url();
1987        assert_eq!(
1988            url,
1989            "https://maps.secondlife.com/secondlife/Beach%20Valley/110/67/24"
1990        );
1991        // The generated form round-trips through the parser.
1992        assert_eq!(url.parse::<Location>(), Ok(location));
1993        Ok(())
1994    }
1995
1996    #[test]
1997    fn test_parse_location_url_slurl() -> Result<(), Box<dyn std::error::Error>> {
1998        assert_eq!(
1999            "http://slurl.com/secondlife/Beach%20Valley/110/67/24".parse::<Location>(),
2000            Ok(Location {
2001                region_name: RegionName::try_new("Beach Valley")?,
2002                x: 110,
2003                y: 67,
2004                z: 24
2005            }),
2006        );
2007        Ok(())
2008    }
2009
2010    #[test]
2011    fn test_parse_location_bare_with_usb_comment() -> Result<(), Box<dyn std::error::Error>> {
2012        assert_eq!(
2013            "Beach%20Valley/110/67/24,MUSTER".parse::<Location>(),
2014            Ok(Location {
2015                region_name: RegionName::try_new("Beach Valley")?,
2016                x: 110,
2017                y: 67,
2018                z: 24
2019            }),
2020        );
2021        Ok(())
2022    }
2023
2024    #[test]
2025    fn test_grid_rectangle_intersection_upper_right_corner()
2026    -> Result<(), Box<dyn std::error::Error>> {
2027        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2028        let rect2 = GridRectangle::new(GridCoordinates::new(15, 15), GridCoordinates::new(25, 25));
2029        assert_eq!(
2030            rect1.intersect(&rect2),
2031            Some(GridRectangle::new(
2032                GridCoordinates::new(15, 15),
2033                GridCoordinates::new(20, 20),
2034            ))
2035        );
2036        Ok(())
2037    }
2038
2039    #[test]
2040    fn test_grid_rectangle_intersection_upper_left_corner() -> Result<(), Box<dyn std::error::Error>>
2041    {
2042        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2043        let rect2 = GridRectangle::new(GridCoordinates::new(5, 15), GridCoordinates::new(15, 25));
2044        assert_eq!(
2045            rect1.intersect(&rect2),
2046            Some(GridRectangle::new(
2047                GridCoordinates::new(10, 15),
2048                GridCoordinates::new(15, 20),
2049            ))
2050        );
2051        Ok(())
2052    }
2053
2054    #[test]
2055    fn test_grid_rectangle_intersection_lower_left_corner() -> Result<(), Box<dyn std::error::Error>>
2056    {
2057        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2058        let rect2 = GridRectangle::new(GridCoordinates::new(5, 5), GridCoordinates::new(15, 15));
2059        assert_eq!(
2060            rect1.intersect(&rect2),
2061            Some(GridRectangle::new(
2062                GridCoordinates::new(10, 10),
2063                GridCoordinates::new(15, 15),
2064            ))
2065        );
2066        Ok(())
2067    }
2068
2069    #[test]
2070    fn test_grid_rectangle_intersection_lower_right_corner()
2071    -> Result<(), Box<dyn std::error::Error>> {
2072        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2073        let rect2 = GridRectangle::new(GridCoordinates::new(15, 5), GridCoordinates::new(25, 15));
2074        assert_eq!(
2075            rect1.intersect(&rect2),
2076            Some(GridRectangle::new(
2077                GridCoordinates::new(15, 10),
2078                GridCoordinates::new(20, 15),
2079            ))
2080        );
2081        Ok(())
2082    }
2083
2084    #[test]
2085    fn test_grid_rectangle_intersection_no_overlap() -> Result<(), Box<dyn std::error::Error>> {
2086        let rect1 = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2087        let rect2 = GridRectangle::new(GridCoordinates::new(30, 30), GridCoordinates::new(40, 40));
2088        assert_eq!(rect1.intersect(&rect2), None);
2089        Ok(())
2090    }
2091
2092    #[test]
2093    fn test_grid_rectangle_expanded_west() {
2094        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2095        assert_eq!(rect.expanded_west(0), rect);
2096        assert_eq!(
2097            rect.expanded_west(3),
2098            GridRectangle::new(GridCoordinates::new(7, 10), GridCoordinates::new(20, 20)),
2099        );
2100        let near_edge =
2101            GridRectangle::new(GridCoordinates::new(2, 10), GridCoordinates::new(20, 20));
2102        assert_eq!(
2103            near_edge.expanded_west(5),
2104            GridRectangle::new(GridCoordinates::new(0, 10), GridCoordinates::new(20, 20)),
2105        );
2106    }
2107
2108    #[test]
2109    fn test_grid_rectangle_expanded_east() {
2110        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2111        assert_eq!(rect.expanded_east(0), rect);
2112        assert_eq!(
2113            rect.expanded_east(3),
2114            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(23, 20)),
2115        );
2116        let near_edge = GridRectangle::new(
2117            GridCoordinates::new(10, 10),
2118            GridCoordinates::new(u32::MAX - 2, 20),
2119        );
2120        assert_eq!(
2121            near_edge.expanded_east(5),
2122            GridRectangle::new(
2123                GridCoordinates::new(10, 10),
2124                GridCoordinates::new(u32::MAX, 20),
2125            ),
2126        );
2127    }
2128
2129    #[test]
2130    fn test_grid_rectangle_expanded_south() {
2131        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2132        assert_eq!(rect.expanded_south(0), rect);
2133        assert_eq!(
2134            rect.expanded_south(3),
2135            GridRectangle::new(GridCoordinates::new(10, 7), GridCoordinates::new(20, 20)),
2136        );
2137        let near_edge =
2138            GridRectangle::new(GridCoordinates::new(10, 2), GridCoordinates::new(20, 20));
2139        assert_eq!(
2140            near_edge.expanded_south(5),
2141            GridRectangle::new(GridCoordinates::new(10, 0), GridCoordinates::new(20, 20)),
2142        );
2143    }
2144
2145    #[test]
2146    fn test_grid_rectangle_expanded_north() {
2147        let rect = GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 20));
2148        assert_eq!(rect.expanded_north(0), rect);
2149        assert_eq!(
2150            rect.expanded_north(3),
2151            GridRectangle::new(GridCoordinates::new(10, 10), GridCoordinates::new(20, 23)),
2152        );
2153        let near_edge = GridRectangle::new(
2154            GridCoordinates::new(10, 10),
2155            GridCoordinates::new(20, u32::MAX - 2),
2156        );
2157        assert_eq!(
2158            near_edge.expanded_north(5),
2159            GridRectangle::new(
2160                GridCoordinates::new(10, 10),
2161                GridCoordinates::new(20, u32::MAX),
2162            ),
2163        );
2164    }
2165
2166    #[cfg(feature = "chumsky")]
2167    #[test]
2168    fn test_url_region_name_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2169        let region_name = "Viterbo";
2170        assert_eq!(
2171            url_region_name_parser().parse(region_name).into_result(),
2172            Ok(RegionName::try_new(region_name)?)
2173        );
2174        Ok(())
2175    }
2176
2177    #[cfg(feature = "chumsky")]
2178    #[test]
2179    fn test_url_region_name_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2180        let region_name = "Da Boom";
2181        let input = region_name.replace(' ', "%20");
2182        assert_eq!(
2183            url_region_name_parser().parse(&input).into_result(),
2184            Ok(RegionName::try_new(region_name)?)
2185        );
2186        Ok(())
2187    }
2188
2189    #[cfg(feature = "chumsky")]
2190    #[test]
2191    fn test_region_name_parser_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2192        let region_name = "Da Boom";
2193        assert_eq!(
2194            region_name_parser().parse(region_name).into_result(),
2195            Ok(RegionName::try_new(region_name)?)
2196        );
2197        Ok(())
2198    }
2199
2200    #[cfg(feature = "chumsky")]
2201    #[test]
2202    fn test_url_location_parser_no_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2203        let region_name = "Viterbo";
2204        let input = format!("{region_name}/1/2/300");
2205        assert_eq!(
2206            url_location_parser().parse(&input).into_result(),
2207            Ok(Location {
2208                region_name: RegionName::try_new(region_name)?,
2209                x: 1,
2210                y: 2,
2211                z: 300
2212            })
2213        );
2214        Ok(())
2215    }
2216
2217    #[cfg(feature = "chumsky")]
2218    #[test]
2219    fn test_url_location_parser_url_whitespace() -> Result<(), Box<dyn std::error::Error>> {
2220        let region_name = "Da Boom";
2221        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
2222        assert_eq!(
2223            url_location_parser().parse(&input).into_result(),
2224            Ok(Location {
2225                region_name: RegionName::try_new(region_name)?,
2226                x: 1,
2227                y: 2,
2228                z: 300
2229            })
2230        );
2231        Ok(())
2232    }
2233
2234    #[cfg(feature = "chumsky")]
2235    #[test]
2236    fn test_url_location_parser_url_whitespace_single_digit_after_space()
2237    -> Result<(), Box<dyn std::error::Error>> {
2238        let region_name = "Foo Bar 3";
2239        let input = format!("{}/1/2/300", region_name.replace(' ', "%20"));
2240        assert_eq!(
2241            url_location_parser().parse(&input).into_result(),
2242            Ok(Location {
2243                region_name: RegionName::try_new(region_name)?,
2244                x: 1,
2245                y: 2,
2246                z: 300
2247            })
2248        );
2249        Ok(())
2250    }
2251
2252    #[cfg(feature = "chumsky")]
2253    #[test]
2254    fn test_region_coordinates_parser() -> Result<(), Box<dyn std::error::Error>> {
2255        assert_eq!(
2256            region_coordinates_parser()
2257                .parse("{ 63.0486, 45.2515, 1501.08 }")
2258                .into_result(),
2259            Ok(RegionCoordinates {
2260                x: 63.0486,
2261                y: 45.2515,
2262                z: 1501.08,
2263            })
2264        );
2265        Ok(())
2266    }
2267}