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}