Skip to main content

ifc_geometry/solid/
bbox.rs

1//! `IfcBoundingBox`: an axis-aligned box, and the trap in what it is aligned
2//! to.
3//!
4//! # It is NOT a world-axis-aligned box
5//!
6//! The box is axis-aligned in the coordinate system of the representation that
7//! contains it -- typically the element's own local placement, which is
8//! routinely rotated relative to the world. Using the corner and the three
9//! extents directly as world AABB min/max gives a wrong box for every element
10//! that is not axis-parallel, which in a real building is most of them. The
11//! corner must be transformed and the extents rotated, then a world AABB
12//! recomputed from the eight corners.
13//!
14//! # Corner is the minimum, extents are positive
15//!
16//! `Corner` is the low corner and the box extends along **+X, +Y, +Z** by the
17//! three dims. All three are `IfcPositiveLengthMeasure`, so a zero or negative
18//! extent is a malformed file, not a degenerate-but-usable box.
19//!
20//! # Two roles
21//!
22//! It appears both as a standalone `Box` representation (a cheap proxy for an
23//! element's extent) and as `IfcBoxedHalfSpace.Enclosure`, where it bounds an
24//! otherwise infinite half space. Same entity, different meaning; see
25//! [`crate::solid::halfspace::BoxedHalfSpace`].
26
27use crate::error::GeometryResult;
28use crate::slots::Slots;
29use ifc_model::{Entity, EntityId};
30
31/// `IfcBoundingBox` attribute slots.
32///
33/// EXPRESS (IFC4 ADD2 TC1): subtypes `IfcGeometricRepresentationItem`, which
34/// declares no explicit attributes, so all four are absolute slots 0-3.
35pub(crate) mod slot {
36    /// `Corner : IfcCartesianPoint`, the minimum corner.
37    pub const CORNER: usize = 0;
38    /// `XDim : IfcPositiveLengthMeasure`.
39    pub const X_DIM: usize = 1;
40    /// `YDim : IfcPositiveLengthMeasure`.
41    pub const Y_DIM: usize = 2;
42    /// `ZDim : IfcPositiveLengthMeasure`.
43    pub const Z_DIM: usize = 3;
44}
45
46/// `IfcBoundingBox`: a box given by a corner and three extents.
47#[derive(Debug, Clone, Copy)]
48pub struct BoundingBox<'m> {
49    slots: Slots<'m>,
50}
51
52impl<'m> BoundingBox<'m> {
53    /// Wrap an entity assumed to be an `IfcBoundingBox`.
54    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
55        Self {
56            slots: Slots::new(id, entity),
57        }
58    }
59
60    /// The entity id.
61    pub fn id(&self) -> EntityId {
62        self.slots.id()
63    }
64
65    /// The `IfcCartesianPoint` reference at the box's minimum corner.
66    ///
67    /// TODO: resolve through the point module once it exists; this crate
68    /// deliberately does not define a competing point view.
69    pub fn corner(&self) -> GeometryResult<EntityId> {
70        self.slots.req_ref(slot::CORNER, "Corner")
71    }
72
73    /// Extent along the local X axis, in file length units.
74    pub fn x_dim(&self) -> GeometryResult<f64> {
75        self.slots.req_f64(slot::X_DIM, "XDim")
76    }
77
78    /// Extent along the local Y axis, in file length units.
79    pub fn y_dim(&self) -> GeometryResult<f64> {
80        self.slots.req_f64(slot::Y_DIM, "YDim")
81    }
82
83    /// Extent along the local Z axis, in file length units.
84    pub fn z_dim(&self) -> GeometryResult<f64> {
85        self.slots.req_f64(slot::Z_DIM, "ZDim")
86    }
87
88    /// All three extents, in local X, Y, Z order.
89    pub fn dimensions(&self) -> GeometryResult<[f64; 3]> {
90        Ok([self.x_dim()?, self.y_dim()?, self.z_dim()?])
91    }
92
93    /// The extents, rejecting the non-positive values the schema forbids.
94    ///
95    /// A zero extent is not a flat-but-usable box: `IfcPositiveLengthMeasure`
96    /// excludes it, and a consumer that accepts it will divide by it somewhere.
97    pub fn checked_dimensions(&self) -> GeometryResult<[f64; 3]> {
98        let dims = self.dimensions()?;
99        for (axis, value) in ["XDim", "YDim", "ZDim"].iter().zip(dims) {
100            // Written as an explicit positive test rather than a negated
101            // comparison so that NaN, which compares false against everything,
102            // lands in the rejection branch instead of slipping through.
103            if !matches!(value.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
104                return Err(self
105                    .slots
106                    .degenerate(format!("{axis} must be positive, found {value}")));
107            }
108        }
109        Ok(dims)
110    }
111
112    /// The local-space maximum corner, given the resolved minimum corner.
113    ///
114    /// Takes the corner coordinates rather than resolving them, because
115    /// `IfcCartesianPoint` belongs to another module. The result is in the
116    /// **same local system** as the input; see the module docs before treating
117    /// it as a world AABB bound.
118    pub fn max_corner_local(&self, corner: [f64; 3]) -> GeometryResult<[f64; 3]> {
119        let [x, y, z] = self.dimensions()?;
120        Ok([corner[0] + x, corner[1] + y, corner[2] + z])
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::solid::testkit::{entity, n, r};
128
129    fn bbox(x: f64, y: f64, z: f64) -> Entity {
130        entity("IFCBOUNDINGBOX", vec![r(10), n(x), n(y), n(z)])
131    }
132
133    #[test]
134    fn corner_precedes_the_three_extents_in_slot_order() {
135        let e = bbox(1.0, 2.0, 3.0);
136        let view = BoundingBox::new(EntityId(1), &e);
137        assert_eq!(view.corner().unwrap(), EntityId(10));
138        assert_eq!(view.dimensions().unwrap(), [1.0, 2.0, 3.0]);
139    }
140
141    /// Corner is the MINIMUM and the box grows along +X/+Y/+Z; treating it as
142    /// a centre halves the box and offsets it.
143    #[test]
144    fn the_corner_is_the_minimum_and_extents_grow_positively() {
145        let e = bbox(2.0, 4.0, 6.0);
146        let view = BoundingBox::new(EntityId(1), &e);
147        assert_eq!(
148            view.max_corner_local([10.0, 20.0, 30.0]).unwrap(),
149            [12.0, 24.0, 36.0]
150        );
151        // A negative-coordinate corner still grows positively.
152        assert_eq!(
153            view.max_corner_local([-1.0, -1.0, -1.0]).unwrap(),
154            [1.0, 3.0, 5.0]
155        );
156    }
157
158    #[test]
159    fn a_non_positive_extent_is_rejected_as_degenerate() {
160        for e in [
161            bbox(0.0, 1.0, 1.0),
162            bbox(1.0, -2.0, 1.0),
163            bbox(1.0, 1.0, 0.0),
164        ] {
165            let view = BoundingBox::new(EntityId(6), &e);
166            let err = view.checked_dimensions().unwrap_err();
167            assert_eq!(err.entity(), Some(EntityId(6)));
168            assert!(
169                view.dimensions().is_ok(),
170                "raw dimensions stay readable for inspection"
171            );
172        }
173        assert_eq!(
174            BoundingBox::new(EntityId(6), &bbox(1.0, 2.0, 3.0))
175                .checked_dimensions()
176                .unwrap(),
177            [1.0, 2.0, 3.0]
178        );
179    }
180
181    #[test]
182    fn a_box_missing_an_extent_names_the_attribute() {
183        let e = entity("IFCBOUNDINGBOX", vec![r(10), n(1.0), n(2.0)]);
184        let err = BoundingBox::new(EntityId(3), &e).z_dim().unwrap_err();
185        assert!(err.to_string().contains("ZDim"));
186    }
187}