Skip to main content

ifc_geometry/select/
entity_selects.rs

1//! The `SELECT` types of the three geometry schemas.
2//!
3//! Each resolves an entity reference to the *branch* of the select it takes,
4//! so callers write an exhaustive `match` the compiler checks instead of a
5//! string comparison they must keep in sync with the schema.
6//!
7//! Every resolver is subtype-aware via [`is_a`]: select members are usually
8//! abstract, so a direct type-name comparison would reject valid files.
9
10use crate::error::{GeometryError, GeometryResult};
11use crate::select::subtype::is_a;
12use ifc_model::{Entity, EntityId, Model, Value};
13
14/// Build the "this is not a permitted member" error for a select.
15fn not_a_member(entity: EntityId, actual: &str, expected: &'static str) -> GeometryError {
16    GeometryError::WrongEntityType {
17        entity,
18        actual: actual.to_string(),
19        expected,
20    }
21}
22
23/// Resolve a reference, then classify it with `f`.
24///
25/// Shared by every select so a dangling reference reports identically
26/// regardless of which attribute held it.
27fn resolve_and_classify<T>(
28    model: &Model,
29    referrer: EntityId,
30    target: EntityId,
31    expected: &'static str,
32    f: impl Fn(&str) -> Option<T>,
33) -> GeometryResult<T> {
34    let entity: &Entity = model.get(target).ok_or(GeometryError::MissingEntity {
35        referrer,
36        missing: target,
37    })?;
38    f(&entity.type_name).ok_or_else(|| not_a_member(target, &entity.type_name, expected))
39}
40
41/// `IfcAxis2Placement` = `IfcAxis2Placement2D` | `IfcAxis2Placement3D`.
42///
43/// The dimensionality of a placement is not stated by the attribute that holds
44/// it; it is the target's type. A 2D placement inside a 3D context is a real
45/// modelling error, so the distinction must survive to the caller.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Axis2Placement {
48    /// `IfcAxis2Placement2D`.
49    TwoD(EntityId),
50    /// `IfcAxis2Placement3D`.
51    ThreeD(EntityId),
52}
53
54impl Axis2Placement {
55    /// Classify a placement reference.
56    pub fn resolve(model: &Model, referrer: EntityId, target: EntityId) -> GeometryResult<Self> {
57        resolve_and_classify(model, referrer, target, "IfcAxis2Placement", |t| {
58            if is_a(t, "IFCAXIS2PLACEMENT2D") {
59                Some(Self::TwoD(target))
60            } else if is_a(t, "IFCAXIS2PLACEMENT3D") {
61                Some(Self::ThreeD(target))
62            } else {
63                None
64            }
65        })
66    }
67
68    /// The referenced entity, whichever branch was taken.
69    pub fn id(&self) -> EntityId {
70        match self {
71            Self::TwoD(id) | Self::ThreeD(id) => *id,
72        }
73    }
74
75    /// How many coordinates this placement works in.
76    pub fn dimension(&self) -> usize {
77        match self {
78            Self::TwoD(_) => 2,
79            Self::ThreeD(_) => 3,
80        }
81    }
82}
83
84/// `IfcBooleanOperand` = `IfcSolidModel` | `IfcHalfSpaceSolid` |
85/// `IfcBooleanResult` | `IfcCsgPrimitive3D` | `IfcTessellatedFaceSet`.
86///
87/// # Why the branch matters
88///
89/// [`Self::HalfSpace`] is the one operand a kernel cannot tessellate on its
90/// own, because a half space is infinite. A caller that treats all operands
91/// alike will hand a kernel something unboundable; distinguishing the branch
92/// here is what lets it clip first.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum BooleanOperand {
95    /// A solid model: swept, brep, CSG or disk-swept.
96    Solid(EntityId),
97    /// An infinite half space. Only meaningful inside a boolean.
98    HalfSpace(EntityId),
99    /// A nested boolean result: the operand tree is recursive.
100    BooleanResult(EntityId),
101    /// An analytic CSG primitive.
102    CsgPrimitive(EntityId),
103    /// A tessellated face set (IFC4 ADD2 permits these as operands).
104    TessellatedFaceSet(EntityId),
105}
106
107impl BooleanOperand {
108    /// Classify a boolean operand reference.
109    pub fn resolve(model: &Model, referrer: EntityId, target: EntityId) -> GeometryResult<Self> {
110        resolve_and_classify(model, referrer, target, "IfcBooleanOperand", |t| {
111            // Order matters: IfcBooleanResult and IfcCsgPrimitive3D are not
112            // IfcSolidModel subtypes, but half spaces and solids overlap
113            // nothing, so any order among the rest is safe.
114            if is_a(t, "IFCHALFSPACESOLID") {
115                Some(Self::HalfSpace(target))
116            } else if is_a(t, "IFCBOOLEANRESULT") {
117                Some(Self::BooleanResult(target))
118            } else if is_a(t, "IFCCSGPRIMITIVE3D") {
119                Some(Self::CsgPrimitive(target))
120            } else if is_a(t, "IFCTESSELLATEDFACESET") {
121                Some(Self::TessellatedFaceSet(target))
122            } else if is_a(t, "IFCSOLIDMODEL") {
123                Some(Self::Solid(target))
124            } else {
125                None
126            }
127        })
128    }
129
130    /// The referenced entity.
131    pub fn id(&self) -> EntityId {
132        match self {
133            Self::Solid(id)
134            | Self::HalfSpace(id)
135            | Self::BooleanResult(id)
136            | Self::CsgPrimitive(id)
137            | Self::TessellatedFaceSet(id) => *id,
138        }
139    }
140
141    /// Is this operand unbounded, and so unusable outside a boolean?
142    pub fn is_unbounded(&self) -> bool {
143        matches!(self, Self::HalfSpace(_))
144    }
145}
146
147/// `IfcCsgSelect` = `IfcBooleanResult` | `IfcCsgPrimitive3D`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum CsgSelect {
150    /// A nested boolean operation.
151    BooleanResult(EntityId),
152    /// A leaf analytic primitive.
153    Primitive(EntityId),
154}
155
156impl CsgSelect {
157    /// Classify a CSG tree root.
158    pub fn resolve(model: &Model, referrer: EntityId, target: EntityId) -> GeometryResult<Self> {
159        resolve_and_classify(model, referrer, target, "IfcCsgSelect", |t| {
160            if is_a(t, "IFCBOOLEANRESULT") {
161                Some(Self::BooleanResult(target))
162            } else if is_a(t, "IFCCSGPRIMITIVE3D") {
163                Some(Self::Primitive(target))
164            } else {
165                None
166            }
167        })
168    }
169
170    /// The referenced entity.
171    pub fn id(&self) -> EntityId {
172        match self {
173            Self::BooleanResult(id) | Self::Primitive(id) => *id,
174        }
175    }
176}
177
178/// `IfcSolidOrShell` = `IfcClosedShell` | `IfcSolidModel`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum SolidOrShell {
181    /// A closed shell: a boundary, not a solid.
182    ClosedShell(EntityId),
183    /// A solid model.
184    Solid(EntityId),
185}
186
187impl SolidOrShell {
188    /// Classify the reference.
189    pub fn resolve(model: &Model, referrer: EntityId, target: EntityId) -> GeometryResult<Self> {
190        resolve_and_classify(model, referrer, target, "IfcSolidOrShell", |t| {
191            if is_a(t, "IFCCLOSEDSHELL") {
192                Some(Self::ClosedShell(target))
193            } else if is_a(t, "IFCSOLIDMODEL") {
194                Some(Self::Solid(target))
195            } else {
196                None
197            }
198        })
199    }
200
201    /// The referenced entity.
202    pub fn id(&self) -> EntityId {
203        match self {
204            Self::ClosedShell(id) | Self::Solid(id) => *id,
205        }
206    }
207}
208
209/// `IfcTrimmingSelect` = `IfcCartesianPoint` | `IfcParameterValue`.
210///
211/// # The one select that is not always a reference
212///
213/// A trim may be a *point* (an entity reference) or a *parameter* (a bare
214/// number wrapped as `IFCPARAMETERVALUE(1.57)`). Both may be present on the
215/// same trim, and `IfcTrimmingPreference` decides which wins. Modelling this
216/// as anything less than both-optional loses information the file carries.
217#[derive(Debug, Clone, PartialEq)]
218pub struct TrimmingSelect {
219    /// Trim given as a Cartesian point.
220    pub point: Option<EntityId>,
221    /// Trim given as a parameter along the basis curve.
222    pub parameter: Option<f64>,
223}
224
225impl TrimmingSelect {
226    /// Read a trim from the aggregate the schema declares it as.
227    ///
228    /// `IfcTrimmedCurve.Trim1` is `SET [1:2] OF IfcTrimmingSelect`, so one
229    /// value holds up to both representations.
230    pub fn from_value(value: &Value) -> Self {
231        let mut out = Self {
232            point: None,
233            parameter: None,
234        };
235        let items: Vec<&Value> = match value {
236            Value::List(items) => items.iter().collect(),
237            single => vec![single],
238        };
239        for item in items {
240            match item {
241                Value::Ref(id) => out.point = Some(*id),
242                other => {
243                    if let Some(n) = other.unwrap_typed().as_f64() {
244                        out.parameter = Some(n);
245                    }
246                }
247            }
248        }
249        out
250    }
251
252    /// Does this trim carry anything at all?
253    pub fn is_empty(&self) -> bool {
254        self.point.is_none() && self.parameter.is_none()
255    }
256}
257
258/// `IfcVectorOrDirection` = `IfcDirection` | `IfcVector`.
259///
260/// A vector carries a magnitude, a direction does not. Collapsing the two
261/// loses the length of an extrusion whose direction is given as a vector.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum VectorOrDirection {
264    /// `IfcDirection`: orientation only.
265    Direction(EntityId),
266    /// `IfcVector`: orientation plus magnitude.
267    Vector(EntityId),
268}
269
270impl VectorOrDirection {
271    /// Classify the reference.
272    pub fn resolve(model: &Model, referrer: EntityId, target: EntityId) -> GeometryResult<Self> {
273        resolve_and_classify(model, referrer, target, "IfcVectorOrDirection", |t| {
274            if is_a(t, "IFCVECTOR") {
275                Some(Self::Vector(target))
276            } else if is_a(t, "IFCDIRECTION") {
277                Some(Self::Direction(target))
278            } else {
279                None
280            }
281        })
282    }
283
284    /// The referenced entity.
285    pub fn id(&self) -> EntityId {
286        match self {
287            Self::Direction(id) | Self::Vector(id) => *id,
288        }
289    }
290
291    /// Does this carry a magnitude of its own?
292    pub fn has_magnitude(&self) -> bool {
293        matches!(self, Self::Vector(_))
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn model_with(id: u64, type_name: &str) -> Model {
302        let mut model = Model::new();
303        model.insert(EntityId(id), Entity::new(type_name, vec![Value::Null; 4]));
304        model
305    }
306
307    /// The headline property: a concrete subtype satisfies an abstract member.
308    #[test]
309    fn a_concrete_solid_resolves_as_the_abstract_solid_model_branch() {
310        let model = model_with(5, "IFCEXTRUDEDAREASOLID");
311        let operand = BooleanOperand::resolve(&model, EntityId(1), EntityId(5)).unwrap();
312        assert_eq!(operand, BooleanOperand::Solid(EntityId(5)));
313        assert!(!operand.is_unbounded());
314    }
315
316    /// The branch a kernel must treat specially.
317    #[test]
318    fn half_space_operands_are_flagged_unbounded() {
319        for t in ["IFCHALFSPACESOLID", "IFCPOLYGONALBOUNDEDHALFSPACE"] {
320            let model = model_with(5, t);
321            let operand = BooleanOperand::resolve(&model, EntityId(1), EntityId(5)).unwrap();
322            assert!(operand.is_unbounded(), "{t} must be unbounded");
323        }
324    }
325
326    #[test]
327    fn nested_boolean_results_are_their_own_branch() {
328        let model = model_with(5, "IFCBOOLEANCLIPPINGRESULT");
329        assert_eq!(
330            BooleanOperand::resolve(&model, EntityId(1), EntityId(5)).unwrap(),
331            BooleanOperand::BooleanResult(EntityId(5))
332        );
333    }
334
335    #[test]
336    fn an_entity_outside_the_select_is_rejected_with_its_actual_type() {
337        let model = model_with(5, "IFCWALL");
338        let err = BooleanOperand::resolve(&model, EntityId(1), EntityId(5)).unwrap_err();
339        assert!(err.to_string().contains("IFCWALL"), "got {err}");
340    }
341
342    #[test]
343    fn a_dangling_reference_is_reported_as_missing_not_as_wrong_type() {
344        let model = Model::new();
345        assert!(matches!(
346            BooleanOperand::resolve(&model, EntityId(1), EntityId(99)).unwrap_err(),
347            GeometryError::MissingEntity { .. }
348        ));
349    }
350
351    #[test]
352    fn placement_dimension_comes_from_the_target_type() {
353        let two = model_with(5, "IFCAXIS2PLACEMENT2D");
354        assert_eq!(
355            Axis2Placement::resolve(&two, EntityId(1), EntityId(5))
356                .unwrap()
357                .dimension(),
358            2
359        );
360        let three = model_with(5, "IFCAXIS2PLACEMENT3D");
361        assert_eq!(
362            Axis2Placement::resolve(&three, EntityId(1), EntityId(5))
363                .unwrap()
364                .dimension(),
365            3
366        );
367    }
368
369    /// Both trim representations may be present at once.
370    #[test]
371    fn a_trim_can_carry_a_point_and_a_parameter_together() {
372        let trim = TrimmingSelect::from_value(&Value::List(vec![
373            Value::Ref(EntityId(7)),
374            Value::Typed {
375                type_name: "IFCPARAMETERVALUE".into(),
376                value: Box::new(Value::Real(0.75)),
377            },
378        ]));
379        assert_eq!(trim.point, Some(EntityId(7)));
380        assert_eq!(trim.parameter, Some(0.75));
381        assert!(!trim.is_empty());
382    }
383
384    #[test]
385    fn a_trim_may_be_a_bare_parameter() {
386        let trim = TrimmingSelect::from_value(&Value::Typed {
387            type_name: "IFCPARAMETERVALUE".into(),
388            value: Box::new(Value::Real(0.0)),
389        });
390        assert_eq!(trim.parameter, Some(0.0));
391        assert_eq!(trim.point, None);
392    }
393
394    /// A vector's magnitude must not be lost by collapsing it to a direction.
395    #[test]
396    fn vectors_are_distinguished_from_directions() {
397        let vector = model_with(5, "IFCVECTOR");
398        assert!(
399            VectorOrDirection::resolve(&vector, EntityId(1), EntityId(5))
400                .unwrap()
401                .has_magnitude()
402        );
403
404        let direction = model_with(5, "IFCDIRECTION");
405        assert!(
406            !VectorOrDirection::resolve(&direction, EntityId(1), EntityId(5))
407                .unwrap()
408                .has_magnitude()
409        );
410    }
411
412    #[test]
413    fn solid_or_shell_separates_boundaries_from_solids() {
414        let shell = model_with(5, "IFCCLOSEDSHELL");
415        assert_eq!(
416            SolidOrShell::resolve(&shell, EntityId(1), EntityId(5)).unwrap(),
417            SolidOrShell::ClosedShell(EntityId(5))
418        );
419        let solid = model_with(5, "IFCFACETEDBREP");
420        assert_eq!(
421            SolidOrShell::resolve(&solid, EntityId(1), EntityId(5)).unwrap(),
422            SolidOrShell::Solid(EntityId(5))
423        );
424    }
425    /// A nested boolean and a leaf primitive are different CSG branches.
426    ///
427    /// Both are valid tree nodes, so a caller that collapses them cannot
428    /// tell a recursion step from a terminal.
429    #[test]
430    fn csg_branches_separate_nested_booleans_from_leaf_primitives() {
431        let mut model = Model::new();
432        model.insert(EntityId(1), Entity::new("IFCBOOLEANRESULT", vec![]));
433        model.insert(EntityId(2), Entity::new("IFCBLOCK", vec![]));
434        model.insert(EntityId(3), Entity::new("IFCPOLYLINE", vec![]));
435        assert_eq!(
436            CsgSelect::resolve(&model, EntityId(9), EntityId(1)).unwrap(),
437            CsgSelect::BooleanResult(EntityId(1))
438        );
439        assert_eq!(
440            CsgSelect::resolve(&model, EntityId(9), EntityId(2)).unwrap(),
441            CsgSelect::Primitive(EntityId(2)),
442            "IfcBlock is an IfcCsgPrimitive3D leaf"
443        );
444        let error = CsgSelect::resolve(&model, EntityId(9), EntityId(3))
445            .expect_err("a polyline is not a CSG node");
446        assert_eq!(error.entity(), Some(EntityId(3)));
447    }
448}