ifc_lite_server/services/data_model/
property_value.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 VALUE resolution: turning a decoded `IfcProperty` attribute
6//! (nominal value, enumerated/list members, complex-property nesting) into
7//! the display string + kind + measure tag + candidate list that
8//! `properties::extract_property` wires onto a `Property`. Split out of
9//! `properties.rs` to keep entity-shape extraction (which pset/property to
10//! walk) separate from value-shape resolution (how to stringify one).
11
12use ifc_lite_core::{AttributeValue, DecodedEntity, EntityDecoder};
13
14/// Guards `resolve_complex_property_value` against a pathological/cyclic
15/// `HasProperties` chain, mirroring the TS `MAX_COMPLEX_PROPERTY_DEPTH` in
16/// `packages/parser/src/property-complex-value.ts` exactly: real IFC nests
17/// `IfcComplexProperty` at most a couple of levels deep.
18pub(super) const MAX_COMPLEX_PROPERTY_DEPTH: u8 = 8;
19
20/// The suffix minted into a complex property's display value when the depth
21/// cap stops the walk with `HasProperties` members still unread (issue
22/// #3972). Byte-identical to `complexPropertyTruncationMarker()` in
23/// `packages/parser/src/property-complex-value.ts` — the two paths feed the
24/// same property panel, CSV/parquet export and compare fingerprints, so a
25/// divergence here reads as a data difference between server and browser.
26///
27/// A suffix rather than a `truncated` flag on `Property`: a flag would have
28/// to be carried through the Rust struct + serde, the per-field parquet
29/// columns, the TS `Property` type, the `serverDataModel` adapter and the
30/// panel renderer, and is silently lost at any one of them — a signal that
31/// never fires, which is the same shape this issue is about. The value
32/// string reaches every reader by construction.
33fn complex_property_truncation_marker() -> String {
34    format!("(truncated: nesting deeper than {MAX_COMPLEX_PROPERTY_DEPTH} levels)")
35}
36
37/// What an `IfcPropertyReferenceValue` reads as (#5475): the referenced
38/// object's `Name`, else its `Identification` (an external reference, a
39/// person), else `#<id>` so the property still reads as present. Mirrors
40/// `resolvePropertyReferenceValue` in
41/// `packages/parser/src/property-reference-value.ts`.
42pub(super) fn property_reference_label(decoder: &mut EntityDecoder, id: u32) -> String {
43    let label = decoder.decode_by_id(id).ok().and_then(|target| {
44        ["Name", "Identification"].iter().find_map(|attribute| {
45            let index = target.ifc_type.attribute_index(attribute)?;
46            target
47                .get_string(index)
48                .filter(|s| !s.trim().is_empty())
49                .map(str::to_string)
50        })
51    });
52    label.unwrap_or_else(|| format!("#{id}"))
53}
54
55/// Resolve an `IfcComplexProperty`'s nested `HasProperties` (EXPRESS:
56/// `[Name, Description, UsageName, HasProperties]`, index 3) into a display
57/// value plus a flat `values` candidate list, recursing into any further
58/// nested `IfcComplexProperty` — mirrors `resolveComplexPropertyValue`
59/// (`packages/parser/src/property-complex-value.ts`) member-for-member:
60///
61/// - Not a list, or an empty list: return the bare `UsageName` (or the "null"
62///   kind when it's absent/empty) — there was nothing nested to lose.
63/// - The depth cap is hit with a NON-EMPTY `HasProperties` still unread:
64///   return `UsageName` suffixed with
65///   `complex_property_truncation_marker()`, or the bare marker when there is
66///   no `UsageName`. Never the bare `UsageName`: that is a plausible,
67///   well-formed value indistinguishable from a complex property that
68///   genuinely has no nested content (issue #3972). The empty-`UsageName`
69///   case matters most — before #3972 it produced an empty display, the
70///   parent `continue`d past it, and the whole nested member vanished while
71///   the parent fell back to showing its OWN `UsageName`, so the reader saw
72///   a real value attributed to the wrong nesting level.
73/// - Each nested member that resolves to a non-empty display becomes one
74///   `"Name: value"` part (or bare `value` when the nested member has no
75///   Name); a member that fails to resolve, or resolves to an empty display,
76///   is skipped — it does NOT poison the whole complex property.
77/// - Nested names are NEVER hoisted to the top level: the complex property
78///   still surfaces as ONE `Property` under its own Name (see
79///   `extract_property`'s call site), so a name collision between a nested
80///   property and a top-level sibling property cannot occur — same as the TS
81///   side, where `parsePropertyValueWithComplex` is called once per
82///   `HasProperties` member and only ever returns a single value for it.
83pub(super) fn resolve_complex_property_value(
84    entity: &DecodedEntity,
85    decoder: &mut EntityDecoder,
86    depth: u8,
87) -> (String, String, Option<String>, Option<Vec<String>>) {
88    let usage_name = entity.get_string(2).map(|s| s.to_string());
89    let bare = |usage_name: Option<String>| match usage_name {
90        Some(u) if !u.is_empty() => (u, "string".to_string(), None, None),
91        _ => (String::new(), "null".to_string(), None, None),
92    };
93
94    let has_properties = match entity.get_list(3) {
95        Some(list) if !list.is_empty() => list,
96        // Absent, not a list, or empty: nothing nested exists, so stopping
97        // here loses nothing and must NOT be marked as a truncation.
98        _ => return bare(usage_name),
99    };
100
101    if depth >= MAX_COMPLEX_PROPERTY_DEPTH {
102        // Members were present and we declined to read them. Say so.
103        let marker = complex_property_truncation_marker();
104        let value = match usage_name {
105            Some(u) if !u.is_empty() => format!("{u} {marker}"),
106            _ => marker,
107        };
108        return (value, "string".into(), None, None);
109    }
110
111    let refs: Vec<u32> = has_properties
112        .iter()
113        .filter_map(|v| v.as_entity_ref())
114        .collect();
115
116    let mut parts: Vec<String> = Vec::new();
117    let mut values: Vec<String> = Vec::new();
118
119    for prop_id in refs {
120        let nested_entity = match decoder.decode_by_id(prop_id) {
121            Ok(e) => e,
122            Err(_) => continue,
123        };
124        let nested_name = nested_entity
125            .get_string(0)
126            .map(|s| s.to_string())
127            .unwrap_or_default();
128        let nested_ty = nested_entity.ifc_type.as_str().to_uppercase();
129        let display = if nested_ty == "IFCCOMPLEXPROPERTY" {
130            resolve_complex_property_value(&nested_entity, decoder, depth + 1).0
131        } else {
132            match super::properties::extract_property(&nested_entity, decoder) {
133                Some(p) => p.property_value,
134                None => continue,
135            }
136        };
137        if display.is_empty() {
138            continue;
139        }
140        if nested_name.is_empty() {
141            parts.push(display.clone());
142        } else {
143            parts.push(format!("{}: {}", nested_name, display));
144        }
145        values.push(display);
146    }
147
148    let value = if !parts.is_empty() {
149        parts.join(", ")
150    } else {
151        usage_name.unwrap_or_default()
152    };
153    let property_type = if value.is_empty() { "null" } else { "string" };
154    let values = if values.is_empty() { None } else { Some(values) };
155    (value, property_type.into(), None, values)
156}
157
158/// Stringify one member of an enumerated / list value, mirroring the WASM
159/// `String(v)` / `String(v[1])`: a typed wrapper `List([type, inner])` yields
160/// the inner, a scalar yields itself. `None` for nulls (dropped, like the TS
161/// `.filter(v => v !== 'null')`).
162fn stringify_member(v: &AttributeValue) -> Option<String> {
163    match v {
164        AttributeValue::List(items) if items.len() == 2 => stringify_scalar(&items[1]),
165        other => stringify_scalar(other),
166    }
167}
168
169fn stringify_scalar(v: &AttributeValue) -> Option<String> {
170    match v {
171        AttributeValue::String(s) => Some(s.clone()),
172        AttributeValue::Enum(e) => Some(e.clone()),
173        AttributeValue::Integer(i) => Some(i.to_string()),
174        AttributeValue::Float(f) => Some(fmt_number(*f)),
175        _ => None,
176    }
177}
178
179/// Stringified members of a `List(...)` attribute (the WASM candidate array
180/// before joining), or `None` when the attribute is not a list or every
181/// member filters out — the display and the `values` wire field both derive
182/// from this, so they can never disagree.
183pub(super) fn member_list(attr: Option<&AttributeValue>) -> Option<Vec<String>> {
184    match attr {
185        Some(AttributeValue::List(items)) => {
186            let parts: Vec<String> = items.iter().filter_map(stringify_member).collect();
187            if parts.is_empty() {
188                None
189            } else {
190                Some(parts)
191            }
192        }
193        _ => None,
194    }
195}
196
197/// The IFC type every member of a list attribute shares (`IFCLABEL` for
198/// `(IFCLABEL('X'),IFCLABEL('Y'))`), or `None` when a member is untyped or the
199/// types differ. Mirrors the WASM `sharedMemberType` (#5224).
200pub(super) fn shared_member_type(attr: Option<&AttributeValue>) -> Option<String> {
201    let AttributeValue::List(items) = attr? else {
202        return None;
203    };
204    let mut shared: Option<String> = None;
205    for item in items {
206        let t = infer_data_type(Some(item))?;
207        match &shared {
208            Some(s) if *s != t => return None,
209            _ => shared = Some(t),
210        }
211    }
212    shared
213}
214
215/// The IFC type tag of a typed-wrapper attribute (`List([String(type), _])`),
216/// upper-cased — mirrors the WASM `inferDataType`.
217pub(super) fn infer_data_type(attr: Option<&AttributeValue>) -> Option<String> {
218    match attr {
219        Some(AttributeValue::List(items)) if items.len() == 2 => match &items[0] {
220            AttributeValue::String(s) => Some(s.to_uppercase()),
221            _ => None,
222        },
223        _ => None,
224    }
225}
226
227/// Normalise an `.ENUM.`-style logical/boolean token (dots already stripped to
228/// the inner letter by the tokenizer, e.g. `Enum("T")`; or a bare `String`).
229fn logical_token(v: &AttributeValue) -> Option<&str> {
230    v.as_enum()
231        .or_else(|| v.as_string())
232        .map(|s| s.trim_matches('.'))
233}
234
235/// Resolve an `IfcPropertySingleValue` NominalValue → (value string, kind, data_type),
236/// matching `parsePropertyValue`'s single-value branch exactly.
237pub(super) fn resolve_single_value(nominal: &AttributeValue) -> (String, String, Option<String>) {
238    // Typed wrapper: List([String(typeName), inner]) — the common conformant case.
239    if let AttributeValue::List(items) = nominal {
240        if items.len() == 2 {
241            if let AttributeValue::String(type_name) = &items[0] {
242                let ty = type_name.to_uppercase();
243                let inner = &items[1];
244
245                if ty.contains("BOOLEAN") {
246                    let b = logical_token(inner) == Some("T");
247                    return (b.to_string(), "boolean".into(), Some(ty));
248                }
249                if ty.contains("LOGICAL") {
250                    return match logical_token(inner) {
251                        Some("U") | Some("X") => (String::new(), "logical".into(), Some(ty)),
252                        Some("T") => ("true".into(), "logical".into(), Some(ty)),
253                        _ => ("false".into(), "logical".into(), Some(ty)),
254                    };
255                }
256                if let Some(n) = inner.as_float() {
257                    // Preserve the IFC-declared numeric kind rather than
258                    // re-inferring from the JS/Rust number-ness.
259                    let kind = if ty == "IFCINTEGER" || ty == "IFCCOUNTMEASURE" {
260                        "integer"
261                    } else if ty == "IFCREAL" || ty.ends_with("MEASURE") || ty.ends_with("RATIO") {
262                        "real"
263                    } else if n.fract() == 0.0 {
264                        "integer"
265                    } else {
266                        "real"
267                    };
268                    return (fmt_number(n), kind.into(), Some(ty));
269                }
270                // String inner (IFCLABEL/IFCTEXT/IFCIDENTIFIER/...).
271                if let Some(s) = inner.as_string() {
272                    return (s.to_string(), "string".into(), Some(ty));
273                }
274                if let Some(e) = inner.as_enum() {
275                    return (e.to_string(), "string".into(), Some(ty));
276                }
277            }
278        }
279    }
280
281    // Untyped scalars.
282    match nominal {
283        AttributeValue::Integer(i) => (i.to_string(), "integer".into(), None),
284        AttributeValue::Float(f) => {
285            let kind = if f.fract() == 0.0 { "integer" } else { "real" };
286            (fmt_number(*f), kind.into(), None)
287        }
288        AttributeValue::String(s) => (s.clone(), "string".into(), None),
289        // Bare enum tokens some authoring tools emit directly in the value slot.
290        AttributeValue::Enum(e) => match e.trim_matches('.') {
291            "T" => ("true".into(), "boolean".into(), None),
292            "F" => ("false".into(), "boolean".into(), None),
293            "U" | "X" => (String::new(), "logical".into(), None),
294            other => (other.to_string(), "string".into(), None),
295        },
296        AttributeValue::Null | AttributeValue::Derived => (String::new(), "null".into(), None),
297        // Anything else (nested list, ref) — stringify defensively.
298        other => (format!("{:?}", other), "string".into(), None),
299    }
300}
301
302/// Render a number the way JS `String(n)` would for the canonical value string
303/// (integers without a trailing `.0`, so `200.0` -> "200").
304pub(super) fn fmt_number(n: f64) -> String {
305    if n.fract() == 0.0 && n.abs() < 1e15 {
306        format!("{}", n as i64)
307    } else {
308        format!("{}", n)
309    }
310}