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}