ifc_lite_export/
quantity_analysis.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Opt-in authored quantity observations. The regular export row's inherited
3//! precedence is intentionally not used here: both sides of a conflict matter.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6use std::sync::Arc;
7
8use ifc_lite_core::{AttributeValue, EntityDecoder, EntityIndex, EntityScanner, IfcType, ProjectUnits};
9use serde::Serialize;
10
11use crate::model::props::{decode_quantity_records, QuantityDecodeMode};
12
13#[path = "quantity_analysis_units.rs"]
14mod units;
15#[path = "quantity_analysis_budget.rs"]
16mod budget;
17#[path = "quantity_analysis_diagnostics.rs"]
18mod diagnostics;
19#[path = "quantity_analysis_relations.rs"]
20mod relations;
21use budget::{AnalysisLimits, ExpansionBudget, LeafBudgetError};
22use diagnostics::Diagnostics;
23
24const MAX_REL_MEMBERS: usize = 100_000;
25const MAX_REF_RECORD_BYTES: usize = 2_000_000;
26const MAX_AUTHORED_ROWS: usize = 100_000;
27const MAX_QUANTITY_LEAF_VISITS: usize = 100_000;
28const MAX_SET_DECODE_BYTES: usize = 128_000_000;
29const MAX_LEAF_DECODE_BYTES: usize = 128_000_000;
30const MAX_SET_VISITS: usize = 100_000;
31const MAX_REL_LINKS: usize = 100_000;
32const MAX_CONFLICT_COMPARISONS: usize = 100_000;
33
34fn report_once(diagnostics: &mut Diagnostics, message: &'static str) {
35    diagnostics.report_once(message);
36}
37
38enum QuantityOrigin { Occurrence, Type(u32) }
39
40struct SourceSets<'a> { ids: &'a [u32], origin: QuantityOrigin }
41impl QuantityOrigin {
42    fn fields(&self) -> (&'static str, Option<u32>) {
43        match self { Self::Occurrence => ("occurrence", None), Self::Type(id) => ("type", Some(*id)) }
44    }
45}
46
47/// An IFC-authored physical simple quantity, with its two source entity IDs.
48#[derive(Debug, Clone, Serialize)]
49#[non_exhaustive]
50pub struct AuthoredQuantity {
51    pub set_name: String,
52    pub quantity_name: String,
53    pub set_id: u32,
54    pub quantity_id: u32,
55    pub kind: &'static str,
56    pub value: f64,
57    /// `occurrence` or `type`; type values are never hidden by occurrence values.
58    pub origin: &'static str,
59    pub type_id: Option<u32>,
60    pub unit: Option<QuantityUnit>,
61    pub unit_diagnostic: Option<String>,
62}
63
64/// Resolved IFC unit, including an explicit quantity override when supplied.
65#[derive(Debug, Clone, Serialize)]
66#[non_exhaustive]
67#[allow(non_snake_case)] // Public IFC attribute spelling is the contract.
68pub struct QuantityUnit {
69    pub symbol: String,
70    pub si_scale: f64,
71    pub source: &'static str,
72    pub unit_id: Option<u32>,
73    /// IFC `IfcNamedUnit.UnitType` token, when it can be resolved.
74    pub UnitType: Option<String>,
75}
76
77/// Same named authored occurrence and type quantity disagree in value or unit.
78#[derive(Debug, Clone, Serialize)]
79#[non_exhaustive]
80pub struct QuantityConflict {
81    pub set_name: String,
82    pub quantity_name: String,
83    pub occurrence_quantity_ids: Vec<u32>,
84    pub type_quantity_ids: Vec<u32>,
85}
86
87/// One actual product occurrence; no row here represents a type product.
88#[derive(Debug, Clone, Serialize)]
89#[non_exhaustive]
90pub struct ProductQuantities {
91    pub ifc_type: String,
92    pub authored: Vec<AuthoredQuantity>,
93    pub conflicts: Vec<QuantityConflict>,
94}
95
96/// Observations keyed by product STEP ID. `product_count` counts IFC product
97/// entities, not represented solids or physical parts.
98#[derive(Debug, Clone, Serialize)]
99#[non_exhaustive]
100pub struct AuthoredQuantityAnalysis {
101    pub product_count: usize,
102    pub products: BTreeMap<u32, ProductQuantities>,
103    pub diagnostics: Vec<String>,
104}
105
106fn add_sets(
107    decoder: &mut EntityDecoder, project_units: &ProjectUnits,
108    index: &EntityIndex,
109    product: &mut ProductQuantities, source_sets: SourceSets<'_>,
110    budget: &mut ExpansionBudget, diagnostics: &mut Diagnostics,
111) {
112    if budget.rows == 0 {
113        if !source_sets.ids.is_empty() {
114            report_once(diagnostics, "authored quantity rows exceed work budget");
115        }
116        return;
117    }
118    let mut seen = HashSet::new();
119    for &set_id in source_sets.ids {
120        if budget.sets == 0 {
121            report_once(diagnostics, "authored quantity set visits exceed work budget");
122            return;
123        }
124        budget.sets -= 1;
125        if !seen.insert(set_id) { continue; }
126        let record_bytes = index.get(&set_id).map_or(0, |(start, end)| end.saturating_sub(*start));
127        if record_bytes > MAX_REF_RECORD_BYTES {
128            diagnostics.push(format!("quantity set #{set_id}: record exceeds work budget"));
129            continue;
130        }
131        if budget.leaf_counts.get(&set_id).is_some_and(|count| *count > budget.leaves) {
132            report_once(diagnostics, "quantity leaf visits exceed work budget");
133            continue;
134        }
135        if record_bytes > budget.set_bytes {
136            report_once(diagnostics, "quantity set decode bytes exceed work budget");
137            continue;
138        }
139        budget.set_bytes -= record_bytes;
140        let Ok(set) = decoder.decode_by_id(set_id) else {
141            diagnostics.push(format!("quantity set #{set_id}: cannot decode"));
142            continue;
143        };
144        if set.ifc_type != IfcType::IfcElementQuantity { continue; }
145        let Some(leaf_count) = set.get(5).and_then(AttributeValue::as_list).map(|items| items.len()) else {
146            diagnostics.push(format!("quantity set #{set_id}: malformed quantity list"));
147            continue;
148        };
149        budget.leaf_counts.insert(set_id, leaf_count);
150        let refs = set.get(5).and_then(AttributeValue::as_list).expect("checked quantity list");
151        if let Err(error) = budget.charge_leaf_refs(refs, index) {
152            match error {
153                LeafBudgetError::Visits => report_once(diagnostics, "quantity leaf visits exceed work budget"),
154                LeafBudgetError::Bytes => report_once(diagnostics, "quantity leaf decode bytes exceed work budget"),
155                LeafBudgetError::Oversized(id) => diagnostics.push(format!(
156                    "quantity set #{set_id}: quantity #{id} record exceeds work budget")),
157            }
158            continue;
159        }
160        if let Some(member_index) = refs.iter().position(|value| value.as_entity_ref().is_none()) {
161            diagnostics.push(format!(
162                "quantity set #{set_id}: malformed Quantities member at index {member_index}"));
163            continue;
164        }
165        let Some(decoded) = decode_quantity_records(
166            decoder, &set, Some(MAX_REL_MEMBERS), QuantityDecodeMode::AuthoredAnalysis,
167        ) else {
168            diagnostics.push(format!("quantity set #{set_id}: malformed or over work budget"));
169            continue;
170        };
171        if decoded.rejected_members > 0 {
172            diagnostics.push(format!("quantity set #{set_id}: {} malformed or unsupported Quantities members; first quantity #{}",
173                decoded.rejected_members, decoded.first_rejected_id.expect("rejected member has an ID")));
174            continue;
175        }
176        for record in decoded.records {
177            if budget.rows == 0 {
178                report_once(diagnostics, "authored quantity rows exceed work budget");
179                return;
180            }
181            budget.rows -= 1;
182            let Some(kind) = units::express_kind(record.value.kind) else {
183                diagnostics.push(format!("quantity #{}: unsupported quantity kind {}", record.id, record.value.kind));
184                continue;
185            };
186            let (unit, unit_diagnostic) = units::resolve_quantity_unit(
187                decoder, project_units, kind, record.unit_id, record.invalid_unit_ref);
188            let (origin, type_id) = source_sets.origin.fields();
189            product.authored.push(AuthoredQuantity {
190                set_name: decoded.name.clone(), quantity_name: record.value.name,
191                set_id, quantity_id: record.id, kind, value: record.value.value,
192                origin, type_id, unit, unit_diagnostic,
193            });
194        }
195    }
196}
197
198fn conflicts(
199    authored: &[AuthoredQuantity], remaining: &mut usize, diagnostics: &mut Diagnostics,
200) -> Vec<QuantityConflict> {
201    type OriginPair<'a> = (Vec<&'a AuthoredQuantity>, Vec<&'a AuthoredQuantity>);
202    let mut grouped: BTreeMap<(&str, &str), OriginPair<'_>> = BTreeMap::new();
203    for quantity in authored {
204        let pair = grouped.entry((&quantity.set_name, &quantity.quantity_name)).or_default();
205        if quantity.origin == "occurrence" { pair.0.push(quantity); } else { pair.1.push(quantity); }
206    }
207    let mut found = Vec::new();
208    for ((set_name, quantity_name), (occ, inherited)) in grouped {
209        if occ.is_empty() || inherited.is_empty() { continue; }
210        let mut different = false;
211        'pairs: for a in &occ {
212            for b in &inherited {
213                if *remaining == 0 {
214                    report_once(diagnostics, "authored quantity conflict comparisons exceed work budget");
215                    return found;
216                }
217                *remaining -= 1;
218                if !units::same_physical_value(a, b) {
219                    different = true;
220                    break 'pairs;
221                }
222            }
223        }
224        if different {
225            found.push(QuantityConflict {
226                set_name: set_name.into(), quantity_name: quantity_name.into(),
227                occurrence_quantity_ids: occ.iter().map(|q| q.quantity_id).collect(),
228                type_quantity_ids: inherited.iter().map(|q| q.quantity_id).collect(),
229            });
230        }
231    }
232    found
233}
234
235/// Read authored quantities without meshing. `ids` restricts emitted product
236/// rows; an empty set emits none. Relationship fan-out and quantity lists have
237/// explicit work budgets and report refusal rather than truncating silently.
238pub fn analyze_authored_quantities(
239    content: &[u8], ids: Option<&HashSet<u32>>,
240) -> AuthoredQuantityAnalysis {
241    analyze_with_budgets(content, ids, MAX_AUTHORED_ROWS, MAX_SET_VISITS, MAX_CONFLICT_COMPARISONS)
242}
243
244fn analyze_with_budgets(
245    content: &[u8], ids: Option<&HashSet<u32>>, row_budget: usize, set_budget: usize,
246    comparison_budget: usize,
247) -> AuthoredQuantityAnalysis {
248    analyze_with_limits(content, ids, AnalysisLimits { rows: row_budget,
249        sets: set_budget, comparisons: comparison_budget, ..Default::default() })
250}
251
252fn analyze_with_limits(
253    content: &[u8], ids: Option<&HashSet<u32>>, limits: AnalysisLimits,
254) -> AuthoredQuantityAnalysis {
255    if ids.is_some_and(HashSet::is_empty) {
256        return AuthoredQuantityAnalysis {
257            product_count: 0, products: BTreeMap::new(), diagnostics: Vec::new(),
258        };
259    }
260    let mut products = BTreeMap::new();
261    let mut project_id = None;
262    let mut products_scan = EntityScanner::new(content);
263    while let Some((id, name, _, _)) = products_scan.next_entity() {
264        if ifc_lite_core::keyword_eq(name, "IFCPROJECT") { project_id.get_or_insert(id); }
265        if ids.is_some_and(|selected| !selected.contains(&id)) { continue; }
266        let kind = ifc_lite_core::ifc_type_from_keyword(name);
267        if kind.is_subtype_of(IfcType::IfcProduct) {
268            products.insert(id, ProductQuantities {
269                ifc_type: kind.name().into(), authored: Vec::new(), conflicts: Vec::new(),
270            });
271        }
272    }
273    let product_count = products.len();
274    let index = Arc::new(ifc_lite_processing::build_entity_index_parallel(content));
275    let mut decoder = EntityDecoder::with_arc_index(content, index.clone());
276    let mut diagnostics = Diagnostics::default();
277    let relations = relations::collect_relationships(
278        content, &mut decoder, &products, limits.links, &mut diagnostics);
279    let direct = relations.direct;
280    let typed = relations.typed;
281    let project_units = project_id.map(|id| ProjectUnits::resolve(&mut decoder, id)).unwrap_or_default();
282    let mut type_defs = HashMap::<u32, Vec<u32>>::new();
283    let mut budget = ExpansionBudget { rows: limits.rows, sets: limits.sets,
284        leaves: limits.leaves, set_bytes: limits.set_bytes, leaf_bytes: limits.leaf_bytes,
285        leaf_counts: HashMap::new() };
286    let mut remaining_comparisons = limits.comparisons;
287    for (id, product) in &mut products {
288        if budget.rows == 0 || budget.sets == 0 {
289            if direct.contains_key(id) || typed.contains_key(id) {
290                report_once(&mut diagnostics, if budget.rows == 0 {
291                    "authored quantity rows exceed work budget"
292                } else { "authored quantity set visits exceed work budget" });
293            }
294            continue;
295        }
296        if let Some(defs) = direct.get(id) {
297            add_sets(&mut decoder, &project_units, &index, product,
298                SourceSets { ids: defs, origin: QuantityOrigin::Occurrence }, &mut budget,
299                &mut diagnostics);
300        }
301        if let Some(&type_id) = typed.get(id) {
302            let defs = type_defs.entry(type_id).or_insert_with(|| {
303                if index.get(&type_id).is_some_and(|(start, end)| end.saturating_sub(*start) > MAX_REF_RECORD_BYTES) {
304                    diagnostics.push(format!("type #{type_id}: record exceeds work budget"));
305                    return Vec::new();
306                }
307                let Ok(type_entity) = decoder.decode_by_id(type_id) else {
308                    diagnostics.push(format!("type #{type_id}: cannot decode"));
309                    return Vec::new();
310                };
311                if !type_entity.ifc_type.is_subtype_of(IfcType::IfcTypeObject) {
312                    diagnostics.push(format!("type #{type_id}: not IfcTypeObject"));
313                    return Vec::new();
314                }
315                let Some(value) = type_entity.get(5) else {
316                    diagnostics.push(format!("type #{type_id}: missing HasPropertySets"));
317                    return Vec::new();
318                };
319                if value.is_null() { return Vec::new(); }
320                let Some(list) = value.as_list() else {
321                    diagnostics.push(format!("type #{type_id}: malformed HasPropertySets"));
322                    return Vec::new();
323                };
324                if list.len() > MAX_REL_MEMBERS {
325                    diagnostics.push(format!("type #{type_id}: HasPropertySets exceeds work budget"));
326                    return Vec::new();
327                }
328                let mut refs = Vec::with_capacity(list.len());
329                for member in list {
330                    let Some(id) = member.as_entity_ref() else {
331                        diagnostics.push(format!("type #{type_id}: malformed HasPropertySets member"));
332                        return Vec::new();
333                    };
334                    refs.push(id);
335                }
336                refs
337            });
338            add_sets(&mut decoder, &project_units, &index, product,
339                SourceSets { ids: defs, origin: QuantityOrigin::Type(type_id) }, &mut budget,
340                &mut diagnostics);
341        }
342        product.conflicts = conflicts(&product.authored, &mut remaining_comparisons, &mut diagnostics);
343    }
344    AuthoredQuantityAnalysis { product_count, products, diagnostics: diagnostics.finish() }
345}
346
347#[cfg(test)]
348#[path = "quantity_analysis_tests.rs"]
349mod tests;