ifc_lite_export/
quantity_analysis_derived.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Opt-in join of authored quantities with canonical analytic source occurrences.
3//! No meshing or second representation/reference walk occurs here.
4
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7use ifc_lite_processing::{extract_analytic_quantity_sources, AnalyticSourceKey,
8    SweptDiskInstance};
9use serde::Serialize;
10
11use crate::quantity_analysis::{analyze_authored_quantities, AuthoredQuantity, QuantityConflict};
12
13/// A source parameter or nominal measurement, never a final cut-product takeoff.
14#[derive(Debug, Clone, Serialize)]
15#[non_exhaustive]
16pub struct DerivedQuantity {
17    pub name: &'static str,
18    pub value: f64,
19    pub unit: &'static str,
20    pub formula: &'static str,
21    pub origin: &'static str,
22    pub source_solid_ids: Vec<u32>,
23    pub limitation: &'static str,
24    pub status: &'static str,
25}
26
27/// One use of a source solid by a product. Repeated map uses remain separate.
28#[derive(Debug, Clone, Serialize)]
29#[non_exhaustive]
30pub struct QuantitySourceOccurrence {
31    pub source_kind: &'static str,
32    pub source: AnalyticSourceKey,
33    pub ordinal: usize,
34    pub solid_id: u32,
35    pub mapping_path: Vec<u32>,
36    pub source_modified: bool,
37    pub status: &'static str,
38    /// Explains unsupported sources and missing nominal source quantities.
39    pub status_reason: Option<String>,
40    pub quantities: Vec<DerivedQuantity>,
41}
42
43/// Authored observations and nominal source estimates for one IFC product.
44#[derive(Debug, Clone, Serialize)]
45#[non_exhaustive]
46pub struct ProductQuantityAnalysis {
47    pub ifc_type: String,
48    pub authored: Vec<AuthoredQuantity>,
49    pub conflicts: Vec<QuantityConflict>,
50    pub source_occurrence_count: usize,
51    pub unique_source_count: usize,
52    pub sources: Vec<QuantitySourceOccurrence>,
53    /// Always absent: overlap, voids and CSG may change the visible product.
54    pub product_total: Option<f64>,
55    pub aggregate_diagnostic: String,
56}
57
58/// Product and unique geometric source counts are different, especially for maps.
59#[derive(Debug, Clone, Serialize)]
60#[non_exhaustive]
61pub struct QuantityAnalysis {
62    pub product_count: usize,
63    pub source_occurrence_count: usize,
64    pub unique_source_count: usize,
65    pub products: BTreeMap<u32, ProductQuantityAnalysis>,
66    pub diagnostics: Vec<String>,
67}
68
69const MAX_JOINED_DIAGNOSTICS: usize = 1_024;
70const JOINED_DIAGNOSTICS_TRUNCATED: &str = "quantity analysis diagnostics exceed work budget";
71
72#[derive(Default)]
73struct JoinedDiagnostics {
74    messages: Vec<String>,
75    seen: HashSet<String>,
76    truncated: bool,
77}
78
79impl JoinedDiagnostics {
80    fn push(&mut self, message: String) {
81        if self.seen.contains(&message) { return; }
82        if self.messages.len() >= MAX_JOINED_DIAGNOSTICS {
83            self.truncated = true;
84        } else {
85            self.seen.insert(message.clone());
86            self.messages.push(message);
87        }
88    }
89
90    fn finish(mut self) -> Vec<String> {
91        if self.truncated { self.messages.push(JOINED_DIAGNOSTICS_TRUNCATED.into()); }
92        self.messages
93    }
94}
95
96fn quantity(name: &'static str, value: f64, unit: &'static str,
97    formula: &'static str, origin: &'static str, solid_id: u32,
98    status: &'static str) -> DerivedQuantity {
99    let limitation = if origin == "authored_source_parameter" {
100        "IFC solid parameter, not an authored product quantity or final cut measurement"
101    } else if unit.starts_with("ifc_file_length_units") {
102        "Unplaced nominal source estimate; occurrence scale, voids and CSG are excluded"
103    } else {
104        "Nominal uncut source estimate; overlap, voids and cutting allowances are excluded"
105    };
106    DerivedQuantity { name, value, unit, formula, origin,
107        source_solid_ids: vec![solid_id], limitation, status }
108}
109
110fn source_status(instance: &SweptDiskInstance) -> (&'static str, Option<String>) {
111    match &instance.status {
112        ifc_lite_geometry::analytic::AnalyticStatus::Unsupported(reason) =>
113            ("unsupported", Some(reason.clone())),
114        ifc_lite_geometry::analytic::AnalyticStatus::Complete if instance.source_modified =>
115            ("source_modified", Some("CSG operand; final product geometry differs from this source".into())),
116        ifc_lite_geometry::analytic::AnalyticStatus::Complete => ("complete", None),
117    }
118}
119
120/// Join three canonical opt-in analytic views with exact authored observations.
121/// `ids` selects actual product occurrences; an empty set returns no rows.
122/// Source measurements are nominal and intentionally never summed into a
123/// product material quantity, cutting length, or physical-part count.
124pub fn analyze_quantities(content: &[u8], ids: Option<&HashSet<u32>>) -> QuantityAnalysis {
125    let authored = analyze_authored_quantities(content, ids);
126    if authored.products.is_empty() {
127        let mut diagnostics = JoinedDiagnostics::default();
128        for message in authored.diagnostics { diagnostics.push(message); }
129        return QuantityAnalysis { product_count: 0, source_occurrence_count: 0,
130            unique_source_count: 0, products: BTreeMap::new(),
131            diagnostics: diagnostics.finish() };
132    }
133    let selected: HashSet<u32> = authored.products.keys().copied().collect();
134    let analytic = extract_analytic_quantity_sources(content, Some(&selected));
135    let disks = analytic.swept_disk_descriptions;
136    let disk_defs = analytic.swept_disk_definitions;
137    let extrusions = analytic.extrusion_definitions;
138    let extrusion_sources: BTreeMap<_, _> = extrusions.sources.iter()
139        .map(|source| (&source.key, source)).collect();
140    let mut diagnostics = JoinedDiagnostics::default();
141    for message in authored.diagnostics.into_iter().chain(disks.diagnostics)
142        .chain(disk_defs.diagnostics).chain(extrusions.diagnostics) {
143        diagnostics.push(message);
144    }
145    let mut products = BTreeMap::new();
146    let mut unique_sources = BTreeSet::new();
147    let mut source_occurrence_count = 0;
148    for (product_id, product) in authored.products {
149        let mut sources = Vec::new();
150        for instance in disk_defs.instances.get(&product_id).into_iter().flatten() {
151            let Some(description) = disks.elements.get(&product_id)
152                .and_then(|items| items.get(instance.ordinal)) else {
153                diagnostics.push(format!("product #{product_id}: swept disk occurrence ordinal {} has no description", instance.ordinal));
154                continue;
155            };
156            if description.solid_id != instance.solid_id ||
157                description.mapping_path != instance.mapping_path {
158                diagnostics.push(format!("product #{product_id}: swept disk source/description mismatch at ordinal {}", instance.ordinal));
159                continue;
160            }
161            let (status, mut status_reason) = source_status(instance);
162            let mut quantities = Vec::new();
163            if let Some(metrics) = description.directrix_metrics() {
164                quantities.push(quantity("centreline_length", metrics.total_length, "m",
165                    "sum(exact directrix segment lengths)", "derived", instance.solid_id, status));
166            }
167            if let Some(nominal) = description.nominal_quantities() {
168                quantities.push(quantity("cross_section_area", nominal.cross_section_area, "m2",
169                    "pi * (outer_radius^2 - inner_radius^2)", "derived", instance.solid_id, status));
170                quantities.push(quantity("nominal_volume", nominal.nominal_volume, "m3",
171                    "cross_section_area * centreline_length", "derived", instance.solid_id, status));
172                quantities.push(quantity("outer_lateral_area", nominal.outer_lateral_area, "m2",
173                    "2 * pi * outer_radius * centreline_length", "derived", instance.solid_id, status));
174                if let Some(inner) = nominal.inner_lateral_area {
175                    quantities.push(quantity("inner_lateral_area", inner, "m2",
176                        "2 * pi * inner_radius * centreline_length", "derived", instance.solid_id, status));
177                }
178            } else if status == "complete" {
179                status_reason = Some("Nominal swept-disk quantities unavailable: source geometry or directrix does not support a valid sweep".into());
180            }
181            unique_sources.insert(instance.source.clone());
182            sources.push(QuantitySourceOccurrence { source_kind: "IfcSweptDiskSolid",
183                source: instance.source.clone(), ordinal: instance.ordinal,
184                solid_id: instance.solid_id, mapping_path: instance.mapping_path.clone(),
185                source_modified: instance.source_modified, status, status_reason, quantities });
186        }
187        for instance in extrusions.instances.get(&product_id).into_iter().flatten() {
188            let Some(definition) = extrusion_sources.get(&instance.source) else {
189                diagnostics.push(format!("product #{product_id}: extrusion source #{} is missing", instance.solid_id));
190                continue;
191            };
192            let (status, mut status_reason) = source_status(instance);
193            let mut quantities = Vec::new();
194            if let Some(depth) = definition.source.depth.filter(|value| value.is_finite()) {
195                quantities.push(quantity("Depth", depth, "ifc_file_length_units",
196                    "IfcExtrudedAreaSolid.Depth", "authored_source_parameter", instance.solid_id, status));
197            }
198            if let Some(nominal) = &definition.nominal_quantities {
199                quantities.push(quantity("profile_area", nominal.profile_area, "ifc_file_length_units2",
200                    "outer profile loop area - inner profile loop areas", "derived", instance.solid_id, status));
201                quantities.push(quantity("projected_height", nominal.projected_height, "ifc_file_length_units",
202                    "Depth * abs(dot(extrusion_direction, profile_normal))", "derived", instance.solid_id, status));
203                quantities.push(quantity("nominal_volume", nominal.nominal_volume, "ifc_file_length_units3",
204                    "profile_area * projected_height", "derived", instance.solid_id, status));
205            } else if status == "complete" {
206                status_reason = Some("Nominal extrusion quantities unavailable: source profile or extrusion does not support a positive volume".into());
207            }
208            unique_sources.insert(instance.source.clone());
209            sources.push(QuantitySourceOccurrence { source_kind: "IfcExtrudedAreaSolid",
210                source: instance.source.clone(), ordinal: instance.ordinal,
211                solid_id: instance.solid_id, mapping_path: instance.mapping_path.clone(),
212                source_modified: instance.source_modified, status, status_reason, quantities });
213        }
214        source_occurrence_count += sources.len();
215        let unique_source_count = sources.iter().map(|item| &item.source).collect::<BTreeSet<_>>().len();
216        let aggregate_diagnostic = if sources.is_empty() {
217            "No supported analytic source is represented; a product total cannot be inferred"
218        } else if sources.iter().any(|item| item.source_modified) {
219            "CSG changes source geometry; source measurements cannot be summed as a product total"
220        } else if sources.iter().any(|item| item.status != "complete") {
221            "Unsupported analytic occurrence prevents a certified product total"
222        } else if sources.len() > 1 {
223            "Multiple source uses may overlap or repeat; a product total cannot be inferred"
224        } else {
225            "Nominal source geometry excludes openings, voids and cutting allowances; a final product total cannot be inferred"
226        };
227        products.insert(product_id, ProductQuantityAnalysis {
228            ifc_type: product.ifc_type, authored: product.authored,
229            conflicts: product.conflicts, source_occurrence_count: sources.len(),
230            unique_source_count, sources, product_total: None,
231            aggregate_diagnostic: aggregate_diagnostic.into() });
232    }
233    QuantityAnalysis { product_count: products.len(), source_occurrence_count,
234        unique_source_count: unique_sources.len(), products, diagnostics: diagnostics.finish() }
235}
236
237#[cfg(test)]
238#[path = "quantity_analysis_derived_tests.rs"]
239mod tests;