Skip to main content

ifc_material/constituent/
set.rs

1//! Source-order-preserving projection of the `IfcMaterialConstituentSet` SET,
2//! and the opt-in fraction policy check.
3
4use ifc_model::EntityId;
5
6use crate::constituent::MaterialConstituent;
7use crate::view::{borrowed_entity, optional_refs, optional_text, MaterialView};
8use crate::{MaterialError, MaterialResult};
9
10borrowed_entity!(MaterialConstituentSet, "IFCMATERIALCONSTITUENTSET");
11
12impl<'m> MaterialConstituentSet<'m> {
13    /// `IfcMaterialConstituentSet.Name`, if given.
14    pub fn name(self) -> MaterialResult<Option<&'m str>> {
15        optional_text(
16            "IFCMATERIALCONSTITUENTSET",
17            self.id(),
18            self.entity(),
19            self.slot("Name")?,
20            "Name",
21        )
22    }
23
24    /// `IfcMaterialConstituentSet.Description`, if given.
25    pub fn description(self) -> MaterialResult<Option<&'m str>> {
26        optional_text(
27            "IFCMATERIALCONSTITUENTSET",
28            self.id(),
29            self.entity(),
30            self.slot("Description")?,
31            "Description",
32        )
33    }
34
35    /// `IfcMaterialConstituentSet.MaterialConstituents`, if the set has any
36    /// members. `None` when the optional attribute slot is absent, distinct
37    /// from an authored-but-empty list (which `optional_refs` rejects).
38    pub fn constituent_ids(self) -> MaterialResult<Option<Vec<EntityId>>> {
39        optional_refs(
40            "IFCMATERIALCONSTITUENTSET",
41            self.id(),
42            self.entity(),
43            self.slot("MaterialConstituents")?,
44            "MaterialConstituents",
45            1,
46        )
47    }
48}
49
50impl<'m> MaterialView<'m> {
51    /// Iterates every `IfcMaterialConstituentSet` instance in the model.
52    ///
53    /// IFC2X3 declares no constituent set: a record of this type in an
54    /// IFC2X3 model is still yielded, and every accessor on it fails with
55    /// [`MaterialError::EntityNotInSchema`] rather than being skipped.
56    pub fn constituent_sets(self) -> impl Iterator<Item = MaterialConstituentSet<'m>> + 'm {
57        let release = self.release();
58        self.model()
59            .of_type("IFCMATERIALCONSTITUENTSET")
60            .map(move |(id, entity)| MaterialConstituentSet::from_known(id, entity, release))
61    }
62}
63
64/// A constituent set whose stated fractions do not describe one whole.
65///
66/// A policy finding, not a schema violation: IFC4 ADD2 TC1 and IFC4X3 ADD2
67/// declare no WHERE rule on `IfcMaterialConstituentSet`, and
68/// `IfcMaterialConstituent.Fraction` is `OPTIONAL`, so a file producing this
69/// diagnostic is valid. Only
70/// [`MaterialView::constituent_fraction_diagnostic`] produces it, when a
71/// caller asks; no accessor fails or normalises because of it.
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum ConstituentFractionDiagnostic {
75    /// Every constituent states a fraction, and their sum differs from 1 by
76    /// more than the caller's tolerance.
77    SumNotOne {
78        /// The `IfcMaterialConstituentSet`.
79        set: EntityId,
80        /// The sum of the stated fractions.
81        sum: f64,
82        /// The tolerance the sum was checked against.
83        tolerance: f64,
84    },
85    /// Some constituents state a fraction and others do not, so the set
86    /// does not say how the whole divides.
87    PartiallyStated {
88        /// The `IfcMaterialConstituentSet`.
89        set: EntityId,
90        /// Constituents stating a fraction, in set order.
91        stated: Vec<EntityId>,
92        /// Constituents stating none, in set order.
93        missing: Vec<EntityId>,
94        /// The sum of the fractions that are stated.
95        stated_sum: f64,
96    },
97}
98
99impl<'m> MaterialView<'m> {
100    /// Opt-in policy check that a constituent set's fractions make one whole.
101    ///
102    /// Returns `Ok(None)` when every constituent states a fraction and the
103    /// sum is within `tolerance` of 1, when no constituent states one, and
104    /// when the set lists no constituents. A sum outside the tolerance is
105    /// [`ConstituentFractionDiagnostic::SumNotOne`]; a mix of stated and
106    /// missing fractions is
107    /// [`ConstituentFractionDiagnostic::PartiallyStated`]. The file is never
108    /// corrected: fractions are reported as authored.
109    ///
110    /// A negative or NaN `tolerance` admits no sum, so every fully stated
111    /// set is reported.
112    ///
113    /// # Errors
114    ///
115    /// The decode errors of the underlying accessors: a malformed member
116    /// list, a member id absent from the model or not an
117    /// `IfcMaterialConstituent`, or a fraction outside `0..=1`. The
118    /// diagnostic itself is never an error.
119    pub fn constituent_fraction_diagnostic(
120        self,
121        set: MaterialConstituentSet<'m>,
122        tolerance: f64,
123    ) -> MaterialResult<Option<ConstituentFractionDiagnostic>> {
124        let Some(ids) = set.constituent_ids()? else {
125            return Ok(None);
126        };
127        let mut stated = Vec::new();
128        let mut missing = Vec::new();
129        let mut sum = 0.0;
130        for id in ids {
131            let entity = self.entity(set.id(), id)?;
132            if !entity.is_type("IFCMATERIALCONSTITUENT") {
133                return Err(MaterialError::ReferenceType {
134                    source_id: set.id(),
135                    target: id,
136                    expected: "IFCMATERIALCONSTITUENT",
137                    actual: entity.type_name.to_string(),
138                });
139            }
140            match MaterialConstituent::from_known(id, entity, set.release()).fraction()? {
141                Some(fraction) => {
142                    stated.push(id);
143                    sum += fraction;
144                }
145                None => missing.push(id),
146            }
147        }
148        if stated.is_empty() {
149            return Ok(None);
150        }
151        if !missing.is_empty() {
152            return Ok(Some(ConstituentFractionDiagnostic::PartiallyStated {
153                set: set.id(),
154                stated,
155                missing,
156                stated_sum: sum,
157            }));
158        }
159        // A NaN tolerance fails this comparison, so it reports.
160        if (sum - 1.0_f64).abs() <= tolerance {
161            return Ok(None);
162        }
163        Ok(Some(ConstituentFractionDiagnostic::SumNotOne {
164            set: set.id(),
165            sum,
166            tolerance,
167        }))
168    }
169}