Skip to main content

egml_core/model/geometry/
envelope.rs

1use crate::error::Error;
2use crate::model::AbstractObject;
3use crate::model::common::{ApplyTransform, Triangulate, Triangulation};
4use crate::model::geometry::DirectPosition;
5use crate::model::geometry::primitives::{AbstractRingKind, Shell};
6use crate::model::geometry::primitives::{
7    AbstractRingProperty, AbstractSurfaceProperty, LinearRing, Polygon, Solid, TriangulatedSurface,
8};
9use crate::model::geometry::primitives::{AbstractSurfaceKind, ShellProperty};
10use nalgebra::{Isometry3, Point3, Rotation3, Scale3, Transform3, Vector3};
11use std::fmt;
12
13/// Axis-aligned bounding box in 3-D space.
14///
15/// An `Envelope` is defined by a lower corner and an upper corner such that
16/// each coordinate component of the lower corner is ≤ the corresponding
17/// component of the upper corner.
18///
19/// Corresponds to `gml:EnvelopeType` in ISO 19136.
20#[derive(Debug, Clone, PartialEq, Default)]
21pub struct Envelope {
22    abstract_object: AbstractObject,
23    lower_corner: DirectPosition,
24    upper_corner: DirectPosition,
25    srs_name: Option<String>,
26    srs_dimension: Option<u8>,
27}
28
29impl Envelope {
30    /// Creates an envelope from explicit lower and upper corners.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`Error::InvalidEnvelopeBounds`] if any coordinate
35    /// component of `lower_corner` is strictly greater than the corresponding
36    /// component of `upper_corner`.
37    ///
38    /// # Examples
39    ///
40    /// ```rust
41    /// use egml_core::model::geometry::{DirectPosition, Envelope};
42    ///
43    /// let lo = DirectPosition::new(0.0, 0.0, 0.0).unwrap();
44    /// let hi = DirectPosition::new(1.0, 2.0, 3.0).unwrap();
45    /// let env = Envelope::new(lo, hi).unwrap();
46    /// assert_eq!(env.size_x(), 1.0);
47    /// ```
48    pub fn new(lower_corner: DirectPosition, upper_corner: DirectPosition) -> Result<Self, Error> {
49        if lower_corner.x() > upper_corner.x() {
50            return Err(Error::InvalidEnvelopeBounds {
51                axis: "x",
52                lower: lower_corner.x(),
53                upper: upper_corner.x(),
54            });
55        }
56        if lower_corner.y() > upper_corner.y() {
57            return Err(Error::InvalidEnvelopeBounds {
58                axis: "y",
59                lower: lower_corner.y(),
60                upper: upper_corner.y(),
61            });
62        }
63        if lower_corner.z() > upper_corner.z() {
64            return Err(Error::InvalidEnvelopeBounds {
65                axis: "z",
66                lower: lower_corner.z(),
67                upper: upper_corner.z(),
68            });
69        }
70
71        Ok(Self {
72            abstract_object: AbstractObject::default(),
73            lower_corner,
74            upper_corner,
75            srs_name: None,
76            srs_dimension: None,
77        })
78    }
79
80    /// Creates an `Envelope` without validating that `lower_corner <= upper_corner`.
81    ///
82    /// # Safety (logical)
83    /// The caller must ensure that each component of `lower_corner` is less than
84    /// or equal to the corresponding component of `upper_corner`. Violating this
85    /// will not cause undefined behavior, but will break the type's invariants
86    /// and produce incorrect results from methods like `contains`, `size`, etc.
87    pub fn new_unchecked(lower_corner: DirectPosition, upper_corner: DirectPosition) -> Self {
88        debug_assert!(
89            {
90                let lc: Point3<f64> = lower_corner.into();
91                let uc: Point3<f64> = upper_corner.into();
92                lc <= uc
93            },
94            "lower_corner must be <= upper_corner"
95        );
96
97        Self {
98            abstract_object: AbstractObject::default(),
99            lower_corner,
100            upper_corner,
101            srs_name: None,
102            srs_dimension: None,
103        }
104    }
105
106    /// Returns the lower (minimum) corner.
107    pub fn lower_corner(&self) -> &DirectPosition {
108        &self.lower_corner
109    }
110
111    /// Returns the upper (maximum) corner.
112    pub fn upper_corner(&self) -> &DirectPosition {
113        &self.upper_corner
114    }
115
116    pub fn set_lower_corner(&mut self, lower_corner: DirectPosition) {
117        self.lower_corner = lower_corner;
118    }
119
120    pub fn set_upper_corner(&mut self, upper_corner: DirectPosition) {
121        self.upper_corner = upper_corner;
122    }
123
124    /// Returns the SRS name identifying the CRS of this envelope's coordinates,
125    /// or `None` if unspecified.
126    pub fn srs_name(&self) -> Option<&str> {
127        self.srs_name.as_deref()
128    }
129
130    /// Returns the coordinate dimension of this envelope's positions,
131    /// or `None` if unspecified.
132    pub fn srs_dimension(&self) -> Option<u8> {
133        self.srs_dimension
134    }
135
136    /// Sets the SRS name identifying the CRS (e.g. `"urn:ogc:def:crs:EPSG::25832"`).
137    pub fn set_srs_name(&mut self, srs_name: impl Into<String>) {
138        self.srs_name = Some(srs_name.into());
139    }
140
141    /// Sets or clears the SRS name.
142    pub fn set_srs_name_opt(&mut self, srs_name: Option<String>) {
143        self.srs_name = srs_name;
144    }
145
146    /// Clears the SRS name.
147    pub fn clear_srs_name(&mut self) {
148        self.srs_name = None;
149    }
150
151    /// Sets the coordinate dimension of this envelope's positions (typically `2` or `3`).
152    pub fn set_srs_dimension(&mut self, srs_dimension: u8) {
153        self.srs_dimension = Some(srs_dimension);
154    }
155
156    /// Sets or clears the coordinate dimension.
157    pub fn set_srs_dimension_opt(&mut self, srs_dimension: Option<u8>) {
158        self.srs_dimension = srs_dimension;
159    }
160
161    /// Clears the coordinate dimension.
162    pub fn clear_srs_dimension(&mut self) {
163        self.srs_dimension = None;
164    }
165
166    /// Returns the diagonal vector from the lower corner to the upper corner.
167    pub fn size(&self) -> Vector3<f64> {
168        let lower_corner_point: Point3<f64> = self.lower_corner.into();
169        let upper_corner_point: Point3<f64> = self.upper_corner.into();
170        upper_corner_point - lower_corner_point
171    }
172
173    /// Returns the extent along the X axis (`upper.x - lower.x`).
174    pub fn size_x(&self) -> f64 {
175        self.upper_corner.x() - self.lower_corner.x()
176    }
177
178    /// Returns the extent along the Y axis (`upper.y - lower.y`).
179    pub fn size_y(&self) -> f64 {
180        self.upper_corner.y() - self.lower_corner.y()
181    }
182
183    /// Returns the extent along the Z axis (`upper.z - lower.z`).
184    pub fn size_z(&self) -> f64 {
185        self.upper_corner.z() - self.lower_corner.z()
186    }
187
188    /// Returns the volume of the box (`size_x * size_y * size_z`).
189    ///
190    /// Returns `0.0` for degenerate envelopes where one or more extents are zero.
191    pub fn volume(&self) -> f64 {
192        self.size_x() * self.size_y() * self.size_z()
193    }
194
195    /// Returns `true` if the lower and upper corners are equal, i.e. the envelope collapses to a point.
196    pub fn is_point(&self) -> bool {
197        self.lower_corner == self.upper_corner
198    }
199
200    /// Returns `true` if exactly one axis has non-zero extent (a line segment).
201    #[allow(clippy::nonminimal_bool)]
202    pub fn is_linear(&self) -> bool {
203        let nx = self.size_x() > 0.0;
204        let ny = self.size_y() > 0.0;
205        let nz = self.size_z() > 0.0;
206        (nx && !ny && !nz) || (!nx && ny && !nz) || (!nx && !ny && nz)
207    }
208
209    /// Returns `true` if exactly two axes have non-zero extent (a flat rectangle).
210    #[allow(clippy::nonminimal_bool)]
211    pub fn is_surface(&self) -> bool {
212        let nx = self.size_x() > 0.0;
213        let ny = self.size_y() > 0.0;
214        let nz = self.size_z() > 0.0;
215        (nx && ny && !nz) || (nx && !ny && nz) || (!nx && ny && nz)
216    }
217
218    /// Returns `true` if all three axes have non-zero extent.
219    pub fn is_volume(&self) -> bool {
220        self.size_x() > 0.0 && self.size_y() > 0.0 && self.size_z() > 0.0
221    }
222
223    fn non_zero_extents(&self) -> u8 {
224        [self.size_x(), self.size_y(), self.size_z()]
225            .iter()
226            .filter(|&&s| s > 0.0)
227            .count() as u8
228    }
229
230    /// Returns the center point of the envelope.
231    ///
232    /// Computed as `lower + size / 2` to avoid overflow with large coordinates.
233    pub fn center(&self) -> DirectPosition {
234        DirectPosition::new(
235            self.lower_corner.x() + self.size_x() / 2.0,
236            self.lower_corner.y() + self.size_y() / 2.0,
237            self.lower_corner.z() + self.size_z() / 2.0,
238        )
239        .expect("envelope corners are finite")
240    }
241
242    /// Returns `true` if `point` lies inside or on the boundary of this envelope.
243    pub fn contains(&self, point: &DirectPosition) -> bool {
244        let lower_corner: Point3<f64> = self.lower_corner.into();
245        let upper_corner: Point3<f64> = self.upper_corner.into();
246        let point: Point3<f64> = (*point).into();
247
248        lower_corner <= point && point <= upper_corner
249    }
250
251    /// Returns `true` if `envelope` is fully contained within (or touches the boundary of) `self`.
252    pub fn contains_envelope(&self, envelope: &Envelope) -> bool {
253        self.contains(&envelope.lower_corner) && self.contains(&envelope.upper_corner)
254    }
255
256    /// Returns `true` if `self` and `envelope` overlap at all (including touching).
257    ///
258    /// Uses the standard per-axis AABB overlap test rather than checking corners:
259    /// two boxes intersect iff they overlap on every axis independently. A
260    /// corner-only check would miss cases like a thin slab passing straight
261    /// through the other box, where neither box's own min/max corner lies
262    /// inside the other.
263    pub fn contains_envelope_partially(&self, envelope: &Envelope) -> bool {
264        self.lower_corner.x() <= envelope.upper_corner.x()
265            && self.upper_corner.x() >= envelope.lower_corner.x()
266            && self.lower_corner.y() <= envelope.upper_corner.y()
267            && self.upper_corner.y() >= envelope.lower_corner.y()
268            && self.lower_corner.z() <= envelope.upper_corner.z()
269            && self.upper_corner.z() >= envelope.lower_corner.z()
270    }
271
272    /// Returns a new envelope expanded by `distance` in every direction.
273    ///
274    /// Each lower-corner coordinate is decreased by `distance` and each
275    /// upper-corner coordinate is increased by `distance`.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`Error::NonFiniteCoordinate`] if `distance` is NaN or infinite,
280    /// or if the resulting coordinates would overflow `f64::MAX`.
281    pub fn enlarge(&self, distance: f64) -> Result<Envelope, Error> {
282        let lower_corner = DirectPosition::new(
283            self.lower_corner.x() - distance,
284            self.lower_corner.y() - distance,
285            self.lower_corner.z() - distance,
286        )?;
287        let upper_corner = DirectPosition::new(
288            self.upper_corner.x() + distance,
289            self.upper_corner.y() + distance,
290            self.upper_corner.z() + distance,
291        )?;
292
293        Envelope::new(lower_corner, upper_corner)
294    }
295}
296
297impl Envelope {
298    /// Computes the union of a slice of envelopes.
299    ///
300    /// Returns `None` if `envelopes` is empty; otherwise returns the smallest
301    /// envelope that contains all envelopes in the slice.
302    pub fn from_envelopes(envelopes: &[Self]) -> Option<Self> {
303        let first = envelopes.first()?;
304
305        let (lower, upper) = envelopes.iter().skip(1).fold(
306            (first.lower_corner, first.upper_corner),
307            |(lo, hi), e| {
308                let new_lo = DirectPosition::new(
309                    lo.x().min(e.lower_corner.x()),
310                    lo.y().min(e.lower_corner.y()),
311                    lo.z().min(e.lower_corner.z()),
312                )
313                .unwrap();
314                let new_hi = DirectPosition::new(
315                    hi.x().max(e.upper_corner.x()),
316                    hi.y().max(e.upper_corner.y()),
317                    hi.z().max(e.upper_corner.z()),
318                )
319                .unwrap();
320                (new_lo, new_hi)
321            },
322        );
323
324        Some(Envelope::new_unchecked(lower, upper))
325    }
326
327    /// Computes the smallest envelope that contains all `points`.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`Error::TooFewElements`] if `points` is empty.
332    pub fn from_points(points: &[DirectPosition]) -> Result<Self, Error> {
333        if points.is_empty() {
334            return Err(Error::TooFewElements {
335                geometry: "Envelope::from_points",
336                minimum: 1,
337                spec: None,
338                id: None,
339                detail: None,
340            });
341        }
342
343        let first = &points[0];
344        let (mut min_x, mut min_y, mut min_z) = (first.x(), first.y(), first.z());
345        let (mut max_x, mut max_y, mut max_z) = (first.x(), first.y(), first.z());
346
347        for point in points.iter().skip(1) {
348            min_x = min_x.min(point.x());
349            min_y = min_y.min(point.y());
350            min_z = min_z.min(point.z());
351            max_x = max_x.max(point.x());
352            max_y = max_y.max(point.y());
353            max_z = max_z.max(point.z());
354        }
355
356        let lower_corner = DirectPosition::new(min_x, min_y, min_z)?;
357        let upper_corner = DirectPosition::new(max_x, max_y, max_z)?;
358
359        Ok(Self::new_unchecked(lower_corner, upper_corner))
360    }
361}
362
363impl Envelope {
364    /// Constructs a [`Solid`] whose boundary is the six faces of the bounding box.
365    ///
366    /// Each face is a [`Polygon`] with an outward-facing [`LinearRing`] exterior.
367    /// Faces are ordered: bottom (−z), top (+z), front (−y), back (+y), left (−x), right (+x).
368    ///
369    /// # Errors
370    ///
371    /// Returns [`Error::NotAVolume`] if the envelope does not have all three extents non-zero.
372    pub fn to_solid(&self) -> Result<Solid, Error> {
373        if !self.is_volume() {
374            return Err(Error::NotAVolume {
375                non_zero_extents: self.non_zero_extents(),
376            });
377        }
378
379        let (lx, ly, lz) = (
380            self.lower_corner.x(),
381            self.lower_corner.y(),
382            self.lower_corner.z(),
383        );
384        let (hx, hy, hz) = (
385            self.upper_corner.x(),
386            self.upper_corner.y(),
387            self.upper_corner.z(),
388        );
389
390        let p000 = DirectPosition::new(lx, ly, lz).expect("envelope corners are finite");
391        let p100 = DirectPosition::new(hx, ly, lz).expect("envelope corners are finite");
392        let p110 = DirectPosition::new(hx, hy, lz).expect("envelope corners are finite");
393        let p010 = DirectPosition::new(lx, hy, lz).expect("envelope corners are finite");
394        let p001 = DirectPosition::new(lx, ly, hz).expect("envelope corners are finite");
395        let p101 = DirectPosition::new(hx, ly, hz).expect("envelope corners are finite");
396        let p111 = DirectPosition::new(hx, hy, hz).expect("envelope corners are finite");
397        let p011 = DirectPosition::new(lx, hy, hz).expect("envelope corners are finite");
398
399        let face_rings: [Vec<DirectPosition>; 6] = [
400            vec![p000, p010, p110, p100], // bottom (−z)
401            vec![p001, p101, p111, p011], // top    (+z)
402            vec![p000, p100, p101, p001], // front  (−y)
403            vec![p010, p011, p111, p110], // back   (+y)
404            vec![p000, p001, p011, p010], // left   (−x)
405            vec![p100, p110, p111, p101], // right  (+x)
406        ];
407
408        let members: Vec<AbstractSurfaceProperty> = face_rings
409            .into_iter()
410            .map(|points| {
411                let ring = LinearRing::new(points).ok()?;
412                let polygon = Polygon::new(
413                    Some(AbstractRingProperty::from_object(
414                        AbstractRingKind::LinearRing(ring),
415                    )),
416                    vec![],
417                )
418                .ok()?;
419                Some(AbstractSurfaceProperty::from_object(
420                    AbstractSurfaceKind::Polygon(polygon),
421                ))
422            })
423            .collect::<Option<_>>()
424            .expect("envelope corners are finite and valid");
425        let shell = Shell::new(members).expect("envelope is valid");
426        let shell_property = ShellProperty::from_object(shell);
427
428        let solid = Solid::new(Some(shell_property)).expect("envelope is valid");
429        Ok(solid)
430    }
431
432    /// Constructs a [`Polygon`] from the flat rectangle of this envelope.
433    ///
434    /// The four corners are wound counter-clockwise when viewed from the
435    /// positive side of the collapsed axis (i.e. outward-facing normal).
436    ///
437    /// # Errors
438    ///
439    /// Returns [`Error::NotASurface`] if the envelope does not have exactly two non-zero extents.
440    pub fn to_polygon(&self) -> Result<Polygon, Error> {
441        if !self.is_surface() {
442            return Err(Error::NotASurface {
443                non_zero_extents: self.non_zero_extents(),
444            });
445        }
446
447        let (lx, ly, lz) = (
448            self.lower_corner.x(),
449            self.lower_corner.y(),
450            self.lower_corner.z(),
451        );
452        let (hx, hy, hz) = (
453            self.upper_corner.x(),
454            self.upper_corner.y(),
455            self.upper_corner.z(),
456        );
457
458        let points = if self.size_z() == 0.0 {
459            // XY plane — normal along +Z
460            vec![
461                DirectPosition::new(lx, ly, lz).expect("envelope corners are finite"),
462                DirectPosition::new(hx, ly, lz).expect("envelope corners are finite"),
463                DirectPosition::new(hx, hy, lz).expect("envelope corners are finite"),
464                DirectPosition::new(lx, hy, lz).expect("envelope corners are finite"),
465            ]
466        } else if self.size_y() == 0.0 {
467            // XZ plane — normal along +Y
468            vec![
469                DirectPosition::new(lx, ly, lz).expect("envelope corners are finite"),
470                DirectPosition::new(lx, ly, hz).expect("envelope corners are finite"),
471                DirectPosition::new(hx, ly, hz).expect("envelope corners are finite"),
472                DirectPosition::new(hx, ly, lz).expect("envelope corners are finite"),
473            ]
474        } else {
475            // YZ plane — normal along +X
476            vec![
477                DirectPosition::new(lx, ly, lz).expect("envelope corners are finite"),
478                DirectPosition::new(lx, hy, lz).expect("envelope corners are finite"),
479                DirectPosition::new(lx, hy, hz).expect("envelope corners are finite"),
480                DirectPosition::new(lx, ly, hz).expect("envelope corners are finite"),
481            ]
482        };
483
484        let ring = LinearRing::new(points).expect("envelope corners are finite and valid");
485        Polygon::new(
486            Some(AbstractRingProperty::from_object(
487                AbstractRingKind::LinearRing(ring),
488            )),
489            vec![],
490        )
491        .map_err(|_| Error::NotASurface {
492            non_zero_extents: self.non_zero_extents(),
493        })
494    }
495
496    /// Triangulates the envelope into a [`TriangulatedSurface`].
497    ///
498    /// - For a surface envelope (`is_surface()`): triangulates the single rectangular face.
499    /// - For a volume envelope (`is_volume()`): triangulates all six bounding faces and merges them.
500    ///
501    /// # Errors
502    ///
503    /// Returns [`Error::NotSurfaceOrVolume`] if the envelope is a point or line segment.
504    pub fn to_triangulated_surface(&self) -> Result<TriangulatedSurface, Error> {
505        if self.is_surface() {
506            self.to_polygon()?
507                .triangulate()
508                .map(Triangulation::into_surface)
509        } else if self.is_volume() {
510            self.to_solid()?
511                .exterior()
512                .as_ref()
513                .expect("must be created")
514                .object()
515                .expect("must be created")
516                .triangulate()
517                .map(Triangulation::into_surface)
518        } else {
519            Err(Error::NotSurfaceOrVolume {
520                non_zero_extents: self.non_zero_extents(),
521            })
522        }
523    }
524}
525
526impl ApplyTransform for Envelope {
527    fn apply_transform(&mut self, transform: Transform3<f64>) {
528        let transformed_lower_corner: Point3<f64> = transform * Point3::from(self.lower_corner);
529        let transformed_upper_corner: Point3<f64> = transform * Point3::from(self.upper_corner);
530
531        self.lower_corner = DirectPosition::new(
532            transformed_lower_corner.x.min(transformed_upper_corner.x),
533            transformed_lower_corner.y.min(transformed_upper_corner.y),
534            transformed_lower_corner.z.min(transformed_upper_corner.z),
535        )
536        .expect("envelope corners are finite");
537        self.upper_corner = DirectPosition::new(
538            transformed_lower_corner.x.max(transformed_upper_corner.x),
539            transformed_lower_corner.y.max(transformed_upper_corner.y),
540            transformed_lower_corner.z.max(transformed_upper_corner.z),
541        )
542        .expect("envelope corners are finite");
543    }
544
545    /// Applies a rigid-body isometry (rotation + translation) to this envelope in place.
546    ///
547    /// Both corners are transformed and the result is re-fitted as an axis-aligned bounding
548    /// box by taking per-axis minima/maxima. This keeps the AABB invariant valid after
549    /// rotation, at the cost of a potentially larger box for non-axis-aligned rotations.
550    ///
551    /// # Examples
552    ///
553    /// ```rust
554    /// use egml_core::model::geometry::{DirectPosition, Envelope};
555    /// use nalgebra::{Isometry3, Vector3};
556    /// use crate::egml_core::model::common::ApplyTransform;
557    ///
558    /// let lo = DirectPosition::new(0.0, 0.0, 0.0).unwrap();
559    /// let hi = DirectPosition::new(1.0, 2.0, 3.0).unwrap();
560    /// let mut env = Envelope::new(lo, hi).unwrap();
561    ///
562    /// let translation = Isometry3::translation(10.0, 0.0, 0.0);
563    /// env.apply_isometry(translation);
564    ///
565    /// assert_eq!(env.lower_corner().x(), 10.0);
566    /// assert_eq!(env.upper_corner().x(), 11.0);
567    /// ```
568    fn apply_isometry(&mut self, isometry: Isometry3<f64>) {
569        let transformed_lower_corner: Point3<f64> = isometry * Point3::from(self.lower_corner);
570        let transformed_upper_corner: Point3<f64> = isometry * Point3::from(self.upper_corner);
571
572        self.lower_corner = DirectPosition::new(
573            transformed_lower_corner.x.min(transformed_upper_corner.x),
574            transformed_lower_corner.y.min(transformed_upper_corner.y),
575            transformed_lower_corner.z.min(transformed_upper_corner.z),
576        )
577        .expect("envelope corners are finite");
578        self.upper_corner = DirectPosition::new(
579            transformed_lower_corner.x.max(transformed_upper_corner.x),
580            transformed_lower_corner.y.max(transformed_upper_corner.y),
581            transformed_lower_corner.z.max(transformed_upper_corner.z),
582        )
583        .expect("envelope corners are finite");
584    }
585
586    /// Applies a pure translation to this envelope in place.
587    ///
588    /// Fast path: translation preserves per-axis ordering, so both corners can be shifted
589    /// directly with no re-fit via min/max — unlike rotation or scale, which can invalidate
590    /// the axis-aligned invariant and require re-deriving the corners.
591    fn apply_translation(&mut self, vector: Vector3<f64>) {
592        self.lower_corner = DirectPosition::new(
593            self.lower_corner.x() + vector.x,
594            self.lower_corner.y() + vector.y,
595            self.lower_corner.z() + vector.z,
596        )
597        .expect("envelope corners are finite");
598        self.upper_corner = DirectPosition::new(
599            self.upper_corner.x() + vector.x,
600            self.upper_corner.y() + vector.y,
601            self.upper_corner.z() + vector.z,
602        )
603        .expect("envelope corners are finite");
604    }
605
606    /// Applies a pure rotation (about the origin) to this envelope in place.
607    ///
608    /// Both corners are rotated and the result is re-fitted as an axis-aligned bounding box
609    /// by taking per-axis minima/maxima, same as [`apply_isometry`](Self::apply_isometry).
610    fn apply_rotation(&mut self, rotation: Rotation3<f64>) {
611        let transformed_lower_corner: Point3<f64> = rotation * Point3::from(self.lower_corner);
612        let transformed_upper_corner: Point3<f64> = rotation * Point3::from(self.upper_corner);
613
614        self.lower_corner = DirectPosition::new(
615            transformed_lower_corner.x.min(transformed_upper_corner.x),
616            transformed_lower_corner.y.min(transformed_upper_corner.y),
617            transformed_lower_corner.z.min(transformed_upper_corner.z),
618        )
619        .expect("envelope corners are finite");
620        self.upper_corner = DirectPosition::new(
621            transformed_lower_corner.x.max(transformed_upper_corner.x),
622            transformed_lower_corner.y.max(transformed_upper_corner.y),
623            transformed_lower_corner.z.max(transformed_upper_corner.z),
624        )
625        .expect("envelope corners are finite");
626    }
627
628    /// Applies a per-axis scale to this envelope in place.
629    ///
630    /// Both corners are scaled and the result is re-fitted via min/max, same as
631    /// [`apply_isometry`](Self::apply_isometry) — a negative scale factor mirrors an axis and
632    /// would otherwise leave the lower corner greater than the upper corner on that axis.
633    fn apply_scale(&mut self, scale: Scale3<f64>) {
634        let transformed_lower_corner = Point3::new(
635            self.lower_corner.x() * scale.vector.x,
636            self.lower_corner.y() * scale.vector.y,
637            self.lower_corner.z() * scale.vector.z,
638        );
639        let transformed_upper_corner = Point3::new(
640            self.upper_corner.x() * scale.vector.x,
641            self.upper_corner.y() * scale.vector.y,
642            self.upper_corner.z() * scale.vector.z,
643        );
644
645        self.lower_corner = DirectPosition::new(
646            transformed_lower_corner.x.min(transformed_upper_corner.x),
647            transformed_lower_corner.y.min(transformed_upper_corner.y),
648            transformed_lower_corner.z.min(transformed_upper_corner.z),
649        )
650        .expect("envelope corners are finite");
651        self.upper_corner = DirectPosition::new(
652            transformed_lower_corner.x.max(transformed_upper_corner.x),
653            transformed_lower_corner.y.max(transformed_upper_corner.y),
654            transformed_lower_corner.z.max(transformed_upper_corner.z),
655        )
656        .expect("envelope corners are finite");
657    }
658}
659
660impl fmt::Display for Envelope {
661    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662        write!(
663            f,
664            "Envelope[{}, {}, {} -> {}, {}, {}]",
665            self.lower_corner.x(),
666            self.lower_corner.y(),
667            self.lower_corner.z(),
668            self.upper_corner.x(),
669            self.upper_corner.y(),
670            self.upper_corner.z()
671        )
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    fn pos(x: f64, y: f64, z: f64) -> DirectPosition {
680        DirectPosition::new(x, y, z).unwrap()
681    }
682
683    fn env(lx: f64, ly: f64, lz: f64, ux: f64, uy: f64, uz: f64) -> Envelope {
684        Envelope::new(pos(lx, ly, lz), pos(ux, uy, uz)).unwrap()
685    }
686
687    #[test]
688    fn apply_translation_shifts_both_corners() {
689        let mut envelope = env(0.0, 0.0, 0.0, 1.0, 2.0, 3.0);
690
691        envelope.apply_translation(Vector3::new(10.0, -5.0, 1.0));
692
693        assert_eq!(envelope, env(10.0, -5.0, 1.0, 11.0, -3.0, 4.0));
694    }
695
696    #[test]
697    fn apply_rotation_refits_axis_aligned_box() {
698        use std::f64::consts::FRAC_PI_2;
699
700        let mut envelope = env(0.0, 0.0, 0.0, 1.0, 2.0, 3.0);
701
702        // 90-degree rotation about Z swaps the X and Y extents.
703        envelope.apply_rotation(Rotation3::from_euler_angles(0.0, 0.0, FRAC_PI_2));
704
705        assert!((envelope.size_x() - 2.0).abs() < 1e-10);
706        assert!((envelope.size_y() - 1.0).abs() < 1e-10);
707        assert!((envelope.size_z() - 3.0).abs() < 1e-10);
708    }
709
710    #[test]
711    fn apply_scale_refits_on_mirroring_negative_scale() {
712        let mut envelope = env(1.0, 1.0, 1.0, 2.0, 3.0, 4.0);
713
714        // Negative X scale mirrors the box, which would otherwise leave lower.x > upper.x.
715        envelope.apply_scale(Scale3::new(-1.0, 2.0, 1.0));
716
717        assert_eq!(envelope, env(-2.0, 2.0, 1.0, -1.0, 6.0, 4.0));
718    }
719
720    #[test]
721    fn from_envelopes_empty_returns_none() {
722        let result = Envelope::from_envelopes(&[]);
723        assert!(result.is_none());
724    }
725
726    #[test]
727    fn from_envelopes_single_returns_same_envelope() {
728        let e = env(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
729        let result = Envelope::from_envelopes(&[e.clone()]).unwrap();
730
731        assert_eq!(result, e);
732    }
733
734    #[test]
735    fn from_envelopes_two_disjoint() {
736        let a = env(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
737        let b = env(5.0, 6.0, 7.0, 8.0, 9.0, 10.0);
738        let result = Envelope::from_envelopes(&[a, b]).unwrap();
739
740        assert_eq!(result, env(0.0, 0.0, 0.0, 8.0, 9.0, 10.0));
741    }
742
743    #[test]
744    fn from_envelopes_overlapping() {
745        let a = env(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
746        let b = env(3.0, 3.0, 3.0, 7.0, 7.0, 7.0);
747        let result = Envelope::from_envelopes(&[a, b]).unwrap();
748
749        assert_eq!(result, env(0.0, 0.0, 0.0, 7.0, 7.0, 7.0));
750    }
751
752    #[test]
753    fn from_envelopes_one_contains_the_other() {
754        let outer = env(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
755        let inner = env(2.0, 3.0, 4.0, 5.0, 6.0, 7.0);
756        let result = Envelope::from_envelopes(&[outer.clone(), inner]).unwrap();
757
758        assert_eq!(result, outer);
759    }
760
761    #[test]
762    fn from_envelopes_multiple() {
763        let a = env(0.0, 10.0, 20.0, 1.0, 11.0, 21.0);
764        let b = env(-5.0, 8.0, 25.0, 2.0, 12.0, 30.0);
765        let c = env(1.0, 9.0, 18.0, 3.0, 15.0, 22.0);
766        let result = Envelope::from_envelopes(&[a, b, c]).unwrap();
767
768        assert_eq!(result, env(-5.0, 8.0, 18.0, 3.0, 15.0, 30.0));
769    }
770
771    #[test]
772    fn from_envelopes_with_negative_coords() {
773        let a = env(-10.0, -20.0, -30.0, -1.0, -2.0, -3.0);
774        let b = env(-5.0, -25.0, -15.0, 0.0, -1.0, 0.0);
775        let result = Envelope::from_envelopes(&[a, b]).unwrap();
776
777        assert_eq!(result, env(-10.0, -25.0, -30.0, 0.0, -1.0, 0.0));
778    }
779
780    #[test]
781    fn from_envelopes_zero_volume_envelopes() {
782        let a = env(1.0, 1.0, 1.0, 1.0, 1.0, 1.0); // point
783        let b = env(3.0, 3.0, 3.0, 3.0, 3.0, 3.0); // point
784        let result = Envelope::from_envelopes(&[a, b]).unwrap();
785
786        assert_eq!(result, env(1.0, 1.0, 1.0, 3.0, 3.0, 3.0));
787    }
788
789    #[test]
790    fn is_point_when_corners_equal() {
791        let e = env(1.0, 2.0, 3.0, 1.0, 2.0, 3.0);
792        assert!(e.is_point());
793        assert!(!e.is_linear());
794        assert!(!e.is_surface());
795        assert!(!e.is_volume());
796    }
797
798    #[test]
799    fn is_linear_along_x() {
800        let e = env(0.0, 0.0, 0.0, 1.0, 0.0, 0.0);
801        assert!(!e.is_point());
802        assert!(e.is_linear());
803        assert!(!e.is_surface());
804        assert!(!e.is_volume());
805    }
806
807    #[test]
808    fn is_linear_along_y() {
809        let e = env(0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
810        assert!(e.is_linear());
811    }
812
813    #[test]
814    fn is_linear_along_z() {
815        let e = env(0.0, 0.0, 0.0, 0.0, 0.0, 1.0);
816        assert!(e.is_linear());
817    }
818
819    #[test]
820    fn is_surface_xy_plane() {
821        let e = env(0.0, 0.0, 0.0, 1.0, 1.0, 0.0);
822        assert!(!e.is_point());
823        assert!(!e.is_linear());
824        assert!(e.is_surface());
825        assert!(!e.is_volume());
826    }
827
828    #[test]
829    fn is_surface_xz_plane() {
830        let e = env(0.0, 0.0, 0.0, 1.0, 0.0, 1.0);
831        assert!(e.is_surface());
832    }
833
834    #[test]
835    fn is_surface_yz_plane() {
836        let e = env(0.0, 0.0, 0.0, 0.0, 1.0, 1.0);
837        assert!(e.is_surface());
838    }
839
840    #[test]
841    fn is_volume_all_extents_nonzero() {
842        let e = env(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
843        assert!(!e.is_point());
844        assert!(!e.is_linear());
845        assert!(!e.is_surface());
846        assert!(e.is_volume());
847    }
848
849    #[test]
850    fn to_polygon_returns_err_for_point() {
851        assert_eq!(
852            env(1.0, 1.0, 1.0, 1.0, 1.0, 1.0).to_polygon(),
853            Err(Error::NotASurface {
854                non_zero_extents: 0
855            })
856        );
857    }
858
859    #[test]
860    fn to_polygon_returns_err_for_linear() {
861        assert_eq!(
862            env(0.0, 0.0, 0.0, 1.0, 0.0, 0.0).to_polygon(),
863            Err(Error::NotASurface {
864                non_zero_extents: 1
865            })
866        );
867    }
868
869    #[test]
870    fn to_polygon_returns_err_for_volume() {
871        assert_eq!(
872            env(0.0, 0.0, 0.0, 1.0, 1.0, 1.0).to_polygon(),
873            Err(Error::NotASurface {
874                non_zero_extents: 3
875            })
876        );
877    }
878
879    #[test]
880    fn to_polygon_xy_plane() {
881        assert!(env(0.0, 0.0, 0.0, 2.0, 3.0, 0.0).to_polygon().is_ok());
882    }
883
884    #[test]
885    fn to_polygon_xz_plane() {
886        assert!(env(0.0, 0.0, 0.0, 2.0, 0.0, 3.0).to_polygon().is_ok());
887    }
888
889    #[test]
890    fn to_polygon_yz_plane() {
891        assert!(env(0.0, 0.0, 0.0, 0.0, 2.0, 3.0).to_polygon().is_ok());
892    }
893
894    #[test]
895    fn to_triangulated_surface_returns_err_for_point() {
896        assert_eq!(
897            env(0.0, 0.0, 0.0, 0.0, 0.0, 0.0).to_triangulated_surface(),
898            Err(Error::NotSurfaceOrVolume {
899                non_zero_extents: 0
900            })
901        );
902    }
903
904    #[test]
905    fn to_triangulated_surface_returns_err_for_linear() {
906        assert_eq!(
907            env(0.0, 0.0, 0.0, 1.0, 0.0, 0.0).to_triangulated_surface(),
908            Err(Error::NotSurfaceOrVolume {
909                non_zero_extents: 1
910            })
911        );
912    }
913
914    #[test]
915    fn to_triangulated_surface_surface_has_two_triangles() {
916        let result = env(0.0, 0.0, 0.0, 2.0, 3.0, 0.0)
917            .to_triangulated_surface()
918            .unwrap();
919        assert_eq!(result.triangles().len(), 2);
920    }
921
922    #[test]
923    fn to_triangulated_surface_volume_has_twelve_triangles() {
924        let result = env(0.0, 0.0, 0.0, 1.0, 1.0, 1.0)
925            .to_triangulated_surface()
926            .unwrap();
927        assert_eq!(result.triangles().len(), 12);
928    }
929
930    #[test]
931    fn envelope_contains() {
932        let lower_corner = DirectPosition::new(1.0, 2.0, 3.0).unwrap();
933        let upper_corner = DirectPosition::new(2.0, 3.0, 4.0).unwrap();
934        let envelope = Envelope::new(lower_corner, upper_corner).unwrap();
935        let point_a = DirectPosition::new(1.5, 2.5, 3.5).unwrap();
936        let point_b = DirectPosition::new(2.5, 3.5, 4.5).unwrap();
937
938        assert!(envelope.contains(&point_a));
939        assert!(!envelope.contains(&point_b));
940    }
941
942    #[test]
943    fn contains_envelope_partially_true_for_overlapping_boxes() {
944        let a = env(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
945        let b = env(5.0, 5.0, 5.0, 15.0, 15.0, 15.0);
946        assert!(a.contains_envelope_partially(&b));
947        assert!(b.contains_envelope_partially(&a));
948    }
949
950    #[test]
951    fn contains_envelope_partially_true_for_disjoint_boxes() {
952        let a = env(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
953        let b = env(20.0, 20.0, 20.0, 30.0, 30.0, 30.0);
954        assert!(!a.contains_envelope_partially(&b));
955        assert!(!b.contains_envelope_partially(&a));
956    }
957
958    #[test]
959    fn contains_envelope_partially_true_for_slab_crossing_with_no_corner_contained() {
960        // A thin slab passing straight through the middle of `a` along the z axis.
961        // Neither box's own min/max corner lies inside the other, so a corner-only
962        // check would wrongly report no overlap.
963        let a = env(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
964        let slab = env(-5.0, -5.0, 4.0, 15.0, 15.0, 6.0);
965        assert!(a.contains_envelope_partially(&slab));
966        assert!(slab.contains_envelope_partially(&a));
967    }
968
969    #[test]
970    fn contains_envelope_partially_true_when_touching_at_boundary() {
971        let a = env(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
972        let b = env(10.0, 10.0, 10.0, 20.0, 20.0, 20.0);
973        assert!(a.contains_envelope_partially(&b));
974    }
975}