Skip to main content

ifc_geometry/resource/
direction.rs

1//! `IfcDirection` and `IfcVector`: orientation, with and without magnitude.
2//!
3//! # DirectionRatios are ratios, not a unit vector
4//!
5//! The attribute is named `DirectionRatios` because only the *ratios* between
6//! the components are meaningful: `(3, 4, 0)` and `(0.6, 0.8, 0)` denote the
7//! same direction, and exporters write both. Nothing in the schema requires
8//! normalization, so any consumer that treats the raw values as a unit vector
9//! is wrong on real files: a 5x-long "unit" axis fed into a placement basis
10//! scales the geometry it positions.
11//!
12//! Hence [`Direction::unit`] normalizes and [`Direction::ratios`] does not,
13//! and the names say which is which.
14//!
15//! # Zero length is degenerate, not zero
16//!
17//! The schema's `MagnitudeGreaterZero` rule forbids an all-zero direction, but
18//! files contain them. Normalizing one produces `NaN`, which then propagates
19//! silently through every transform it touches until geometry disappears far
20//! from the cause. [`Direction::unit`] returns
21//! [`crate::GeometryError::Degenerate`] instead.
22//!
23//! # 2D or 3D
24//!
25//! `DirectionRatios` is `LIST [2:3]`, so a direction in a 2D context has two
26//! components. As with points, promotion to 3D is explicit.
27
28use crate::error::GeometryResult;
29use crate::slots::Slots;
30use ifc_model::{Entity, EntityId, Model};
31
32/// Attribute slots, absolute STEP positions including inherited attributes.
33pub(crate) mod slot {
34    /// `IfcDirection` (supertype `IfcGeometricRepresentationItem` declares no
35    /// explicit attributes, so this index is its own).
36    pub mod direction {
37        /// `DirectionRatios : LIST [2:3] OF IfcReal`.
38        pub const DIRECTION_RATIOS: usize = 0;
39    }
40
41    /// `IfcVector`.
42    pub mod vector {
43        /// `Orientation : IfcDirection`.
44        pub const ORIENTATION: usize = 0;
45        /// `Magnitude : IfcLengthMeasure`.
46        pub const MAGNITUDE: usize = 1;
47    }
48}
49
50/// A borrowed view of an `IfcDirection`.
51#[derive(Debug, Clone, Copy)]
52pub struct Direction<'m> {
53    slots: Slots<'m>,
54}
55
56impl<'m> Direction<'m> {
57    /// Wrap an entity assumed to be an `IfcDirection`.
58    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
59        Self {
60            slots: Slots::new(id, entity),
61        }
62    }
63
64    /// The entity id.
65    pub fn id(&self) -> EntityId {
66        self.slots.id()
67    }
68
69    /// The raw `DirectionRatios`, unnormalized and of the file's own length.
70    pub fn ratios(&self) -> GeometryResult<Vec<f64>> {
71        self.slots
72            .req_f64_list(slot::direction::DIRECTION_RATIOS, "DirectionRatios")
73    }
74
75    /// How many ratios the direction carries: 2 or 3.
76    pub fn dimension(&self) -> GeometryResult<usize> {
77        let n = self.ratios()?.len();
78        match n {
79            2 | 3 => Ok(n),
80            other => Err(self.slots.degenerate(format!(
81                "DirectionRatios has {other} entries, expected 2 or 3"
82            ))),
83        }
84    }
85
86    /// The ratios promoted to 3D with `z = 0`, still unnormalized.
87    ///
88    /// A 2D direction's third component is genuinely zero (it lies in the
89    /// plane), unlike a 2D point's z, so padding here is not a guess. It is
90    /// still a separate call from [`Self::ratios`] so a caller that needs the
91    /// dimension can ask for it.
92    pub fn ratios_3d(&self) -> GeometryResult<[f64; 3]> {
93        let r = self.ratios()?;
94        match r.len() {
95            2 => Ok([r[0], r[1], 0.0]),
96            3 => Ok([r[0], r[1], r[2]]),
97            other => Err(self.slots.degenerate(format!(
98                "DirectionRatios has {other} entries, expected 2 or 3"
99            ))),
100        }
101    }
102
103    /// The direction as a normalized 3D vector.
104    ///
105    /// Fails with [`crate::GeometryError::Degenerate`] on a zero-length direction
106    /// rather than returning `NaN` components, because a `NaN` here is found
107    /// three subsystems later as missing geometry.
108    pub fn unit(&self) -> GeometryResult<[f64; 3]> {
109        let v = self.ratios_3d()?;
110        let scale = v
111            .iter()
112            .map(|component| component.abs())
113            .fold(0.0, f64::max);
114        if scale == 0.0 {
115            return Err(self
116                .slots
117                .degenerate("DirectionRatios are all zero, so there is no direction"));
118        }
119        if !scale.is_finite() {
120            return Err(self
121                .slots
122                .degenerate("DirectionRatios must be finite before normalization"));
123        }
124
125        // Scale before squaring: finite ratios near f64::MAX otherwise overflow
126        // their norm to infinity and collapse to a false zero direction.
127        let scaled = [v[0] / scale, v[1] / scale, v[2] / scale];
128        let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
129        let unit = [scaled[0] / length, scaled[1] / length, scaled[2] / length];
130        if !unit.iter().all(|component| component.is_finite()) {
131            return Err(self
132                .slots
133                .degenerate("DirectionRatios did not derive a finite unit direction"));
134        }
135        Ok(unit)
136    }
137}
138
139/// A borrowed view of an `IfcVector`: a direction plus a length.
140///
141/// IFC separates the two because `IfcDirection` deliberately has no magnitude.
142/// A vector's `Magnitude` may legitimately be `0.0` (the schema only requires
143/// `>= 0`), giving a zero vector with a well-defined orientation -- so a zero
144/// magnitude is **not** an error here, unlike a zero direction.
145#[derive(Debug, Clone, Copy)]
146pub struct Vector<'m> {
147    slots: Slots<'m>,
148}
149
150impl<'m> Vector<'m> {
151    /// Wrap an entity assumed to be an `IfcVector`.
152    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
153        Self {
154            slots: Slots::new(id, entity),
155        }
156    }
157
158    /// The entity id.
159    pub fn id(&self) -> EntityId {
160        self.slots.id()
161    }
162
163    /// The `IfcDirection` giving this vector's orientation.
164    pub fn orientation_ref(&self) -> GeometryResult<EntityId> {
165        self.slots.req_ref(slot::vector::ORIENTATION, "Orientation")
166    }
167
168    /// The vector's length, in project length units.
169    pub fn magnitude(&self) -> GeometryResult<f64> {
170        self.slots.req_f64(slot::vector::MAGNITUDE, "Magnitude")
171    }
172
173    /// The vector resolved to components: unit orientation times magnitude.
174    pub fn components(&self, model: &'m Model) -> GeometryResult<[f64; 3]> {
175        let magnitude = self.magnitude()?;
176        let unit = resolve_unit(model, self.id(), self.orientation_ref()?)?;
177        Ok([
178            unit[0] * magnitude,
179            unit[1] * magnitude,
180            unit[2] * magnitude,
181        ])
182    }
183}
184
185/// Resolve a reference that must be an `IfcDirection`, normalized to 3D.
186///
187/// Placements and transformation operators both take optional direction
188/// references and both need the same dangling/wrong-type/degenerate handling.
189pub fn resolve_unit(model: &Model, referrer: EntityId, id: EntityId) -> GeometryResult<[f64; 3]> {
190    direction_view(model, referrer, id)?.unit()
191}
192
193/// Resolve a reference that must be an `IfcDirection`, keeping raw ratios.
194///
195/// Use when the caller needs the unnormalized values, e.g. to inspect
196/// dimension before deciding what the direction means.
197pub fn resolve_ratios_3d(
198    model: &Model,
199    referrer: EntityId,
200    id: EntityId,
201) -> GeometryResult<[f64; 3]> {
202    direction_view(model, referrer, id)?.ratios_3d()
203}
204
205/// Resolve and type-check a direction reference.
206fn direction_view<'m>(
207    model: &'m Model,
208    referrer: EntityId,
209    id: EntityId,
210) -> GeometryResult<Direction<'m>> {
211    crate::resource::resolve::direction(model, referrer, id)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::error::GeometryError;
218    use ifc_model::Value;
219
220    fn direction_entity(values: &[f64]) -> Entity {
221        Entity::new(
222            "IFCDIRECTION",
223            vec![Value::List(
224                values.iter().copied().map(Value::Real).collect(),
225            )],
226        )
227    }
228
229    fn close(a: [f64; 3], b: [f64; 3]) -> bool {
230        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-12)
231    }
232
233    /// The attribute is `DirectionRatios`, and exporters take that literally.
234    #[test]
235    fn direction_ratios_are_returned_unnormalized() {
236        let e = direction_entity(&[3.0, 4.0, 0.0]);
237        let d = Direction::new(EntityId(1), &e);
238        assert_eq!(d.ratios().unwrap(), vec![3.0, 4.0, 0.0]);
239        assert!(close(d.unit().unwrap(), [0.6, 0.8, 0.0]));
240    }
241
242    #[test]
243    fn two_dimensional_directions_report_their_dimension() {
244        let e = direction_entity(&[1.0, 0.0]);
245        let d = Direction::new(EntityId(1), &e);
246        assert_eq!(d.dimension().unwrap(), 2);
247        assert_eq!(d.ratios_3d().unwrap(), [1.0, 0.0, 0.0]);
248    }
249
250    /// Normalizing `(0,0,0)` yields `NaN`, which then poisons every transform
251    /// downstream and surfaces as geometry missing for no visible reason.
252    #[test]
253    fn zero_length_direction_is_degenerate_rather_than_nan() {
254        let e = direction_entity(&[0.0, 0.0, 0.0]);
255        let d = Direction::new(EntityId(3), &e);
256        let err = d.unit().unwrap_err();
257        assert!(matches!(err, GeometryError::Degenerate { .. }));
258        assert!(err.to_string().contains("#3"), "got: {err}");
259    }
260
261    /// Ratios are scale-free: any finite non-zero scale carries orientation.
262    #[test]
263    fn finite_extreme_ratios_normalize_without_underflow_or_overflow() {
264        let tiny = direction_entity(&[1e-300, 0.0, 0.0]);
265        assert_eq!(
266            Direction::new(EntityId(1), &tiny).unit().unwrap(),
267            [1.0, 0.0, 0.0]
268        );
269
270        let huge = direction_entity(&[f64::MAX, f64::MAX, 0.0]);
271        let unit = Direction::new(EntityId(2), &huge).unit().unwrap();
272        let expected = 1.0 / 2.0_f64.sqrt();
273        assert!(close(unit, [expected, expected, 0.0]));
274    }
275
276    #[test]
277    fn a_single_ratio_is_degenerate() {
278        let e = direction_entity(&[1.0]);
279        let d = Direction::new(EntityId(1), &e);
280        assert!(d.dimension().is_err());
281        assert!(d.ratios_3d().is_err());
282    }
283
284    #[test]
285    fn missing_direction_ratios_names_the_entity_and_attribute() {
286        let e = Entity::new("IFCDIRECTION", vec![]);
287        let err = Direction::new(EntityId(8), &e).ratios().unwrap_err();
288        assert!(err.to_string().contains("#8"), "got: {err}");
289        assert!(err.to_string().contains("DirectionRatios"), "got: {err}");
290    }
291
292    #[test]
293    fn vector_components_are_orientation_times_magnitude() {
294        let mut model = Model::new();
295        model.insert(EntityId(1), direction_entity(&[3.0, 4.0, 0.0]));
296        let v = Entity::new(
297            "IFCVECTOR",
298            vec![
299                Value::Ref(EntityId(1)),
300                Value::Typed {
301                    type_name: "IFCLENGTHMEASURE".into(),
302                    value: Box::new(Value::Real(10.0)),
303                },
304            ],
305        );
306        let view = Vector::new(EntityId(2), &v);
307        assert_eq!(view.magnitude().unwrap(), 10.0);
308        assert!(close(view.components(&model).unwrap(), [6.0, 8.0, 0.0]));
309    }
310
311    /// `MagGreaterOrEqualZero` permits zero, so a zero-length vector with a
312    /// valid orientation is legal IFC and must not be rejected.
313    #[test]
314    fn zero_magnitude_vector_is_legal_and_yields_the_zero_vector() {
315        let mut model = Model::new();
316        model.insert(EntityId(1), direction_entity(&[0.0, 0.0, 1.0]));
317        let v = Entity::new("IFCVECTOR", vec![Value::Ref(EntityId(1)), Value::Real(0.0)]);
318        assert_eq!(
319            Vector::new(EntityId(2), &v).components(&model).unwrap(),
320            [0.0, 0.0, 0.0]
321        );
322    }
323
324    #[test]
325    fn a_vector_pointing_at_a_non_direction_reports_the_wrong_type() {
326        let mut model = Model::new();
327        model.insert(EntityId(1), Entity::new("IFCCARTESIANPOINT", vec![]));
328        let v = Entity::new("IFCVECTOR", vec![Value::Ref(EntityId(1)), Value::Real(1.0)]);
329        let err = Vector::new(EntityId(2), &v).components(&model).unwrap_err();
330        assert!(matches!(
331            err,
332            GeometryError::WrongEntityType {
333                expected: "IfcDirection",
334                ..
335            }
336        ));
337    }
338
339    #[test]
340    fn a_dangling_direction_reference_names_the_referrer() {
341        let model = Model::new();
342        let err = resolve_unit(&model, EntityId(5), EntityId(99)).unwrap_err();
343        assert_eq!(err.entity(), Some(EntityId(5)));
344    }
345
346    #[test]
347    fn resolving_ratios_keeps_them_unnormalized() {
348        let mut model = Model::new();
349        model.insert(EntityId(1), direction_entity(&[0.0, 2.0]));
350        assert_eq!(
351            resolve_ratios_3d(&model, EntityId(2), EntityId(1)).unwrap(),
352            [0.0, 2.0, 0.0]
353        );
354    }
355}