1use 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#[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 pub origin: &'static str,
59 pub type_id: Option<u32>,
60 pub unit: Option<QuantityUnit>,
61 pub unit_diagnostic: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize)]
66#[non_exhaustive]
67#[allow(non_snake_case)] pub struct QuantityUnit {
69 pub symbol: String,
70 pub si_scale: f64,
71 pub source: &'static str,
72 pub unit_id: Option<u32>,
73 pub UnitType: Option<String>,
75}
76
77#[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#[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#[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
235pub 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;