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}