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}