ifc_lite_server/services/data_model/
properties.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Property set extraction.
6
7use super::property_value::{
8    fmt_number, infer_data_type, member_list, property_reference_label, shared_member_type, resolve_complex_property_value,
9    resolve_single_value,
10};
11use super::types::{EntityJob, Property, PropertySet};
12use ifc_lite_core::{DecodedEntity, EntityDecoder};
13use rayon::prelude::*;
14use std::sync::Arc;
15
16/// Extract all property sets and their properties.
17pub(super) fn extract_properties(
18    jobs: &[EntityJob],
19    content: &Arc<Vec<u8>>,
20    entity_index: &Arc<ifc_lite_core::EntityIndex>,
21) -> Vec<PropertySet> {
22    // First, collect all PropertySet entities
23    // PERF: Use eq_ignore_ascii_case to avoid string allocation per comparison
24    let pset_jobs: Vec<_> = jobs
25        .iter()
26        .filter(|job| job.type_name.eq_ignore_ascii_case("IFCPROPERTYSET"))
27        .collect();
28
29    tracing::debug!(count = pset_jobs.len(), "Extracting property sets");
30
31    pset_jobs
32        .par_iter()
33        .filter_map(|job| {
34            let mut local_decoder =
35                EntityDecoder::with_arc_index(content.as_slice(), entity_index.clone());
36            let entity = local_decoder.decode_at(job.start, job.end).ok()?;
37
38            // IfcPropertySet: [0]=GlobalId, [1]=OwnerHistory, [2]=Name, [3]=Description, [4]=HasProperties
39            // `Name` is OPTIONAL per the schema (inherited from `IfcRoot`) —
40            // bailing the whole closure with `?` on a null/non-string Name
41            // would drop a pset with genuinely resolvable properties before
42            // the keep condition below ever runs, breaking the exact parity
43            // with the browser/WASM path's `typeof psetAttrs[2] === 'string'
44            // ? psetAttrs[2] : ''` (`columnar-parser.ts`), which always
45            // normalizes a missing Name to `''` rather than discarding the
46            // pset. Mirror that here instead of using `?`.
47            let pset_name = entity.get_string(2).unwrap_or_default().to_string();
48            // `HasProperties` is a mandatory, non-empty SET per the schema, so
49            // a `$`/malformed value here means the file itself is invalid —
50            // but the browser path still tolerates it (`Array.isArray` check,
51            // falling through with an empty `properties`) rather than
52            // discarding the pset outright. Match that: treat a missing/
53            // malformed list as empty rather than bailing on `?`.
54            let has_properties = entity.get_list(4).unwrap_or(&[]);
55
56            let mut properties = Vec::new();
57
58            // Extract properties from HasProperties list
59            for prop_ref in has_properties.iter() {
60                if let Some(prop_id) = prop_ref.as_entity_ref() {
61                    if let Ok(prop_entity) = local_decoder.decode_by_id(prop_id) {
62                        if let Some(prop) = extract_property(&prop_entity, &mut local_decoder) {
63                            properties.push(prop);
64                        }
65                    }
66                }
67            }
68
69            // A PropertySet with a real Name is real evidence the file links
70            // this element to it, even when every member failed to resolve
71            // (issue #3963): dropping it wholesale is indistinguishable from
72            // "this element was never linked to any pset via
73            // IfcRelDefinesByProperties" in the first place. Mirrors the
74            // browser/WASM path's keep condition (`columnar-parser.ts`:
75            // `properties.length > 0 || psetName`) — only an unnamed,
76            // fully-unresolved set is dropped.
77            if properties.is_empty() && pset_name.is_empty() {
78                return None;
79            }
80
81            Some(PropertySet {
82                pset_id: job.id,
83                pset_name,
84                properties,
85            })
86        })
87        .collect()
88}
89
90/// Extract a single property from IfcProperty entity.
91///
92/// Mirrors the WASM path's `parsePropertyValue`
93/// (`packages/parser/src/on-demand-extractors.ts`) so server-parsed properties
94/// carry the SAME resolved value + kind + measure tag as the in-browser parse.
95/// STEP wraps values as typed tokens (`IFCLABEL('X')`, `IFCBOOLEAN(.T.)`), which
96/// the decoder stores as `AttributeValue::List([String(type), inner])`; the old
97/// code only matched bare `String`/`Float` and emitted `format!("{:?}")` Debug
98/// garbage for every text/boolean value — this resolves them properly.
99pub(super) fn extract_property(
100    entity: &DecodedEntity,
101    decoder: &mut EntityDecoder,
102) -> Option<Property> {
103    use ifc_lite_core::AttributeValue;
104    // All IfcProperty subtypes carry Name at attribute 0.
105    let property_name = entity.get_string(0)?.to_string();
106    let ty = entity.ifc_type.as_str().to_uppercase();
107
108    // `values` mirrors the WASM `parsePropertyValue().values` candidate array
109    // (issue #1766): IDS facet checks pass when ANY candidate matches. Emitted
110    // only when non-empty — the client treats an empty array as absent.
111    let mut data_type_mixed = false;
112    let (property_value, property_type, data_type, values) = match ty.as_str() {
113        // [Name, Description, NominalValue, Unit]
114        "IFCPROPERTYSINGLEVALUE" => {
115            let (v, k, d) = resolve_single_value(entity.get(2)?);
116            (v, k, d, None)
117        }
118
119        // [Name, Description, EnumerationValues (list), EnumerationReference]
120        // Carries the one type its members share (#5224).
121        "IFCPROPERTYENUMERATEDVALUE" => {
122            let members = member_list(entity.get(2));
123            let joined = members.as_ref().map(|m| m.join(", ")).unwrap_or_default();
124            (joined, "string".into(), shared_member_type(entity.get(2)), members)
125        }
126
127        // [Name, Description, ListValues (list), Unit]
128        "IFCPROPERTYLISTVALUE" => {
129            let members = member_list(entity.get(2));
130            let joined = members.as_ref().map(|m| m.join(", ")).unwrap_or_default();
131            (joined, "string".into(), shared_member_type(entity.get(2)), members)
132        }
133
134        // [Name, Description, UpperBoundValue, LowerBoundValue, Unit, SetPointValue]
135        "IFCPROPERTYBOUNDEDVALUE" => {
136            let upper = entity.get(2).and_then(|v| v.as_float());
137            let lower = entity.get(3).and_then(|v| v.as_float());
138            let set_point = entity.get(5).and_then(|v| v.as_float());
139            let display_value = set_point.or(upper).or(lower);
140            match display_value {
141                None => (String::new(), "null".into(), None, None),
142                Some(dv) => {
143                    let mut display = fmt_number(dv);
144                    if let (Some(lo), Some(hi)) = (lower, upper) {
145                        // en-dash to match the WASM display exactly.
146                        display.push_str(&format!(
147                            " [{} \u{2013} {}]",
148                            fmt_number(lo),
149                            fmt_number(hi)
150                        ));
151                    }
152                    let data_type = infer_data_type(entity.get(5))
153                        .or_else(|| infer_data_type(entity.get(2)))
154                        .or_else(|| infer_data_type(entity.get(3)));
155                    // Every defined bound is a candidate, deduped exactly like
156                    // the WASM side: lower always; upper unless == lower;
157                    // setPoint unless it equals either bound.
158                    let mut candidates: Vec<String> = Vec::new();
159                    if let Some(lo) = lower {
160                        candidates.push(fmt_number(lo));
161                    }
162                    if let Some(hi) = upper {
163                        if Some(hi) != lower {
164                            candidates.push(fmt_number(hi));
165                        }
166                    }
167                    if let Some(sp) = set_point {
168                        if Some(sp) != lower && Some(sp) != upper {
169                            candidates.push(fmt_number(sp));
170                        }
171                    }
172                    let values = if candidates.is_empty() {
173                        None
174                    } else {
175                        Some(candidates)
176                    };
177                    // The value is a display STRING ("5 [2 – 8]"), so keep kind
178                    // `string` — a `real` kind would make the client `Number()`
179                    // it to NaN. The Lists cell derives from the value, and the
180                    // measure tag still rides on `data_type`.
181                    (display, "string".into(), data_type, values)
182                }
183            }
184        }
185
186        // [Name, Description, DefiningValues (list), DefinedValues (list), ...]
187        "IFCPROPERTYTABLEVALUE" => {
188            // Mirror the WASM gate exactly: BOTH DefiningValues and DefinedValues
189            // must be lists (else the whole property resolves to null) — a
190            // malformed table with `$` DefinedValues must not fabricate a
191            // display/candidates that only the server would match.
192            let rows = match entity.get(2) {
193                Some(AttributeValue::List(items)) => items.len(),
194                _ => 0,
195            };
196            let defined_is_list = matches!(entity.get(3), Some(AttributeValue::List(_)));
197            if rows > 0 && defined_is_list {
198                // Candidates are defining THEN defined values, both filtered —
199                // matching the WASM table branch's ordering.
200                let mut members = member_list(entity.get(2)).unwrap_or_default();
201                members.extend(member_list(entity.get(3)).unwrap_or_default());
202                let values = if members.is_empty() {
203                    None
204                } else {
205                    Some(members)
206                };
207                data_type_mixed = true;
208                (
209                    format!("Table ({} rows)", rows),
210                    "string".into(),
211                    None,
212                    values,
213                )
214            } else {
215                (String::new(), "null".into(), None, None)
216            }
217        }
218
219        // [Name, Description, UsageName, PropertyReference]: the reference is
220        // slot 3 in IFC2X3, IFC4 and IFC4X3 alike (#5475).
221        "IFCPROPERTYREFERENCEVALUE" => match entity.get(3).and_then(|v| v.as_entity_ref()) {
222            Some(id) => (property_reference_label(decoder, id), "string".into(), None, None),
223            None => (String::new(), "null".into(), None, None),
224        },
225
226        // [Name, Description, UsageName, HasProperties] — issue #3963. Mirrors
227        // `resolveComplexPropertyValue` in
228        // packages/parser/src/property-complex-value.ts, which IS the spec:
229        // flatten each resolvable nested property into a "Name: value" part,
230        // join with ", ", and fall back to the bare UsageName (or nothing)
231        // when no nested member resolves. Recurses into further nested
232        // IFCCOMPLEXPROPERTY members up to the same depth cap as the TS side.
233        "IFCCOMPLEXPROPERTY" => {
234            let (v, k, d, vs) = resolve_complex_property_value(entity, decoder, 0);
235            (v, k, d, vs)
236        }
237
238        _ => return None,
239    };
240
241    Some(Property {
242        property_name,
243        property_value,
244        property_type,
245        data_type,
246        data_type_mixed,
247        values,
248    })
249}