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::resource::point::CartesianPoint;
29use crate::resource::resolve;
30use crate::slots::Slots;
31use ifc_model::{Entity, EntityId, Model};
32
33/// `IfcBoundingBox` attribute slots.
34///
35/// EXPRESS (IFC4 ADD2 TC1): subtypes `IfcGeometricRepresentationItem`, which
36/// declares no explicit attributes, so all four are absolute slots 0-3.
37pub(crate) mod slot {
38    /// `Corner : IfcCartesianPoint`, the minimum corner.
39    pub const CORNER: usize = 0;
40    /// `XDim : IfcPositiveLengthMeasure`.
41    pub const X_DIM: usize = 1;
42    /// `YDim : IfcPositiveLengthMeasure`.
43    pub const Y_DIM: usize = 2;
44    /// `ZDim : IfcPositiveLengthMeasure`.
45    pub const Z_DIM: usize = 3;
46}
47
48/// `IfcBoundingBox`: a box given by a corner and three extents.
49#[derive(Debug, Clone, Copy)]
50pub struct BoundingBox<'m> {
51    slots: Slots<'m>,
52}
53
54impl<'m> BoundingBox<'m> {
55    /// Wrap an entity assumed to be an `IfcBoundingBox`.
56    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
57        Self {
58            slots: Slots::new(id, entity),
59        }
60    }
61
62    /// The entity id.
63    pub fn id(&self) -> EntityId {
64        self.slots.id()
65    }
66
67    /// The `IfcCartesianPoint` reference at the box's minimum corner.
68    ///
69    /// [`Self::corner_point`] resolves it through `resource::point`, which
70    /// owns the point view; this module does not define a competing one.
71    pub fn corner(&self) -> GeometryResult<EntityId> {
72        self.slots.req_ref(slot::CORNER, "Corner")
73    }
74
75    /// The minimum corner as a typed point view, resolved from the model.
76    ///
77    /// Its coordinates are local to the containing representation; see the
78    /// module docs before treating them as world coordinates.
79    pub fn corner_point<'v>(&self, model: &'v Model) -> GeometryResult<CartesianPoint<'v>> {
80        resolve::cartesian_point(model, self.id(), self.corner()?)
81    }
82
83    /// Extent along the local X axis, in file length units.
84    pub fn x_dim(&self) -> GeometryResult<f64> {
85        self.slots.req_f64(slot::X_DIM, "XDim")
86    }
87
88    /// Extent along the local Y axis, in file length units.
89    pub fn y_dim(&self) -> GeometryResult<f64> {
90        self.slots.req_f64(slot::Y_DIM, "YDim")
91    }
92
93    /// Extent along the local Z axis, in file length units.
94    pub fn z_dim(&self) -> GeometryResult<f64> {
95        self.slots.req_f64(slot::Z_DIM, "ZDim")
96    }
97
98    /// All three extents, in local X, Y, Z order.
99    pub fn dimensions(&self) -> GeometryResult<[f64; 3]> {
100        Ok([self.x_dim()?, self.y_dim()?, self.z_dim()?])
101    }
102
103    /// The extents, rejecting the non-positive values the schema forbids.
104    ///
105    /// A zero extent is not a flat-but-usable box: `IfcPositiveLengthMeasure`
106    /// excludes it, and a consumer that accepts it will divide by it somewhere.
107    pub fn checked_dimensions(&self) -> GeometryResult<[f64; 3]> {
108        let dims = self.dimensions()?;
109        for (axis, value) in ["XDim", "YDim", "ZDim"].iter().zip(dims) {
110            // Written as an explicit positive test rather than a negated
111            // comparison so that NaN, which compares false against everything,
112            // lands in the rejection branch instead of slipping through.
113            if !matches!(value.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
114                return Err(self
115                    .slots
116                    .degenerate(format!("{axis} must be positive, found {value}")));
117            }
118        }
119        Ok(dims)
120    }
121
122    /// The local-space maximum corner, given the resolved minimum corner.
123    ///
124    /// Takes the corner coordinates rather than resolving them, because
125    /// `IfcCartesianPoint` belongs to another module. The result is in the
126    /// **same local system** as the input; see the module docs before treating
127    /// it as a world AABB bound.
128    pub fn max_corner_local(&self, corner: [f64; 3]) -> GeometryResult<[f64; 3]> {
129        let [x, y, z] = self.dimensions()?;
130        Ok([corner[0] + x, corner[1] + y, corner[2] + z])
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::solid::testkit::{entity, n, r};
138
139    fn bbox(x: f64, y: f64, z: f64) -> Entity {
140        entity("IFCBOUNDINGBOX", vec![r(10), n(x), n(y), n(z)])
141    }
142
143    #[test]
144    fn corner_precedes_the_three_extents_in_slot_order() {
145        let e = bbox(1.0, 2.0, 3.0);
146        let view = BoundingBox::new(EntityId(1), &e);
147        assert_eq!(view.corner().unwrap(), EntityId(10));
148        assert_eq!(view.dimensions().unwrap(), [1.0, 2.0, 3.0]);
149    }
150
151    #[test]
152    fn the_corner_resolves_to_its_point_and_feeds_the_max_corner() {
153        use crate::solid::testkit::{list, model};
154        let point = entity(
155            "IFCCARTESIANPOINT",
156            vec![list(vec![n(1.0), n(2.0), n(3.0)])],
157        );
158        let m = model(vec![(10, point)]);
159        let e = bbox(1.0, 1.0, 1.0);
160        let view = BoundingBox::new(EntityId(1), &e);
161        let corner = view.corner_point(&m).unwrap().coordinates_3d().unwrap();
162        assert_eq!(view.max_corner_local(corner).unwrap(), [2.0, 3.0, 4.0]);
163    }
164
165    #[test]
166    fn a_corner_that_is_not_a_point_or_is_dangling_is_a_typed_error() {
167        use crate::solid::testkit::model;
168        let m = model(vec![(10, entity("IFCDIRECTION", vec![]))]);
169        let e = bbox(1.0, 1.0, 1.0);
170        let view = BoundingBox::new(EntityId(1), &e);
171        assert!(matches!(
172            view.corner_point(&m).unwrap_err(),
173            crate::GeometryError::WrongEntityType {
174                entity: EntityId(10),
175                ..
176            }
177        ));
178        let err = view.corner_point(&Model::new()).unwrap_err();
179        assert_eq!(err.entity(), Some(EntityId(1)));
180    }
181
182    /// Corner is the MINIMUM and the box grows along +X/+Y/+Z; treating it as
183    /// a centre halves the box and offsets it.
184    #[test]
185    fn the_corner_is_the_minimum_and_extents_grow_positively() {
186        let e = bbox(2.0, 4.0, 6.0);
187        let view = BoundingBox::new(EntityId(1), &e);
188        assert_eq!(
189            view.max_corner_local([10.0, 20.0, 30.0]).unwrap(),
190            [12.0, 24.0, 36.0]
191        );
192        // A negative-coordinate corner still grows positively.
193        assert_eq!(
194            view.max_corner_local([-1.0, -1.0, -1.0]).unwrap(),
195            [1.0, 3.0, 5.0]
196        );
197    }
198
199    #[test]
200    fn a_non_positive_extent_is_rejected_as_degenerate() {
201        for e in [
202            bbox(0.0, 1.0, 1.0),
203            bbox(1.0, -2.0, 1.0),
204            bbox(1.0, 1.0, 0.0),
205        ] {
206            let view = BoundingBox::new(EntityId(6), &e);
207            let err = view.checked_dimensions().unwrap_err();
208            assert_eq!(err.entity(), Some(EntityId(6)));
209            assert!(
210                view.dimensions().is_ok(),
211                "raw dimensions stay readable for inspection"
212            );
213        }
214        assert_eq!(
215            BoundingBox::new(EntityId(6), &bbox(1.0, 2.0, 3.0))
216                .checked_dimensions()
217                .unwrap(),
218            [1.0, 2.0, 3.0]
219        );
220    }
221
222    #[test]
223    fn a_box_missing_an_extent_names_the_attribute() {
224        let e = entity("IFCBOUNDINGBOX", vec![r(10), n(1.0), n(2.0)]);
225        let err = BoundingBox::new(EntityId(3), &e).z_dim().unwrap_err();
226        assert!(err.to_string().contains("ZDim"));
227    }
228}