Skip to main content

sl_types/
map.rs

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