openbim_ifcx/compose/
flatten.rs

1//! Flattening: merge every input node that shares a path into one node.
2//!
3//! This is the first half of composition, upstream's
4//! `FlattenCompositionInput` (`src/ifcx-core/composition/compose.ts` in
5//! buildingSMART/IFC5-development).
6
7use std::sync::Arc;
8
9use indexmap::IndexMap;
10use serde_json::Value;
11
12use crate::model::IfcxNode;
13
14/// All opinions about one path, merged in layer order.
15///
16/// Upstream calls this a pre-composition node. References are still paths;
17/// composition resolves them.
18#[derive(Debug, Clone, Default, PartialEq)]
19pub struct FlatNode {
20    /// Child name to referenced path. A `None` value survives flattening so
21    /// that composition can delete a child inherited from elsewhere.
22    pub children: IndexMap<String, Option<String>>,
23    /// Inherit name to referenced path. `null` entries are already applied
24    /// and removed.
25    pub inherits: IndexMap<String, String>,
26    /// Attribute values. A JSON `null` is kept as a value, as upstream does;
27    /// it does not delete the attribute.
28    pub attributes: IndexMap<String, Arc<Value>>,
29}
30
31impl FlatNode {
32    /// Merges one later opinion into this node.
33    ///
34    /// For each map, entries of `node` are applied in their order and replace
35    /// an existing entry of the same name in place:
36    ///
37    /// - `children`: the value is stored as written, `None` included;
38    /// - `inherits`: `None` removes the entry, a path sets it;
39    /// - `attributes`: the value is stored as written, `null` included.
40    ///
41    /// The node's `path` and unknown fields are not looked at.
42    pub fn merge(&mut self, node: &IfcxNode) {
43        if let Some(children) = &node.children {
44            for (name, child) in children {
45                self.children.insert(name.clone(), child.clone());
46            }
47        }
48        if let Some(inherits) = &node.inherits {
49            for (name, inherit) in inherits {
50                match inherit {
51                    // shift_remove so a later re-add goes to the end, like a
52                    // JavaScript object delete followed by a set.
53                    None => {
54                        self.inherits.shift_remove(name);
55                    }
56                    Some(path) => {
57                        self.inherits.insert(name.clone(), path.clone());
58                    }
59                }
60            }
61        }
62        if let Some(attributes) = &node.attributes {
63            for (name, value) in attributes {
64                self.attributes
65                    .insert(name.clone(), Arc::new(value.clone()));
66            }
67        }
68    }
69
70    /// Merges one later opinion into this node, moving its maps and attribute
71    /// values instead of copying them. Same rules as [`merge`](Self::merge).
72    pub fn merge_owned(&mut self, node: IfcxNode) {
73        self.merge_parts(node.children, node.inherits, node.attributes);
74    }
75
76    fn merge_parts(
77        &mut self,
78        children: Option<IndexMap<String, Option<String>>>,
79        inherits: Option<IndexMap<String, Option<String>>>,
80        attributes: Option<IndexMap<String, Value>>,
81    ) {
82        if let Some(children) = children {
83            // `extend` inserts in order and replaces an existing key in place.
84            self.children.extend(children);
85        }
86        for (name, inherit) in inherits.into_iter().flatten() {
87            match inherit {
88                None => {
89                    self.inherits.shift_remove(&name);
90                }
91                Some(path) => {
92                    self.inherits.insert(name, path);
93                }
94            }
95        }
96        if let Some(attributes) = attributes {
97            self.attributes.extend(
98                attributes
99                    .into_iter()
100                    .map(|(name, value)| (name, Arc::new(value))),
101            );
102        }
103    }
104}
105
106/// Merges input nodes into one [`FlatNode`] per path.
107///
108/// This borrows the nodes, so every attribute value is copied into the
109/// result. When the nodes are not needed afterwards, [`flatten_owned`] gives
110/// the same result without copying them, which is several times faster on
111/// large models.
112///
113/// `nodes` must be in layer order, weakest first: the nodes of one file in
114/// file order, and for a layer stack the nodes of each layer after those of
115/// the layers it overrides. Later nodes win, as described on
116/// [`FlatNode::merge`].
117///
118/// The result is keyed by path, in order of each path's first appearance.
119/// Every map keeps insertion order. Upstream stores these maps in JavaScript
120/// objects, which list integer-like keys such as `"2"` first; that ordering
121/// quirk is not reproduced, the content is the same.
122/// Paths are compared as plain strings, so `a/b` is a node of its own here;
123/// composition applies it to child `b` of `a`.
124///
125/// ```
126/// use openbim_ifcx::{flatten, IfcxNode};
127///
128/// let layers: Vec<IfcxNode> = serde_json::from_str(r#"[
129///     {"path": "w", "children": {"Body": "b1"}, "inherits": {"Type": "t"},
130///      "attributes": {"x::height": 2.5}},
131///     {"path": "w", "children": {"Body": null}, "inherits": {"Type": null},
132///      "attributes": {"x::height": 3.0}}
133/// ]"#)?;
134/// let flat = flatten(&layers);
135/// let wall = &flat["w"];
136/// assert_eq!(wall.children["Body"], None);
137/// assert!(wall.inherits.is_empty());
138/// assert_eq!(*wall.attributes["x::height"], 3.0);
139/// # Ok::<(), serde_json::Error>(())
140/// ```
141pub fn flatten<'a, I>(nodes: I) -> IndexMap<String, FlatNode>
142where
143    I: IntoIterator<Item = &'a IfcxNode>,
144{
145    let mut flat: IndexMap<String, FlatNode> = IndexMap::new();
146    for node in nodes {
147        // Look up by &str first so a known path is not cloned again.
148        match flat.get_mut(node.path.as_str()) {
149            Some(existing) => existing.merge(node),
150            None => {
151                let mut merged = FlatNode::default();
152                merged.merge(node);
153                flat.insert(node.path.clone(), merged);
154            }
155        }
156    }
157    flat
158}
159
160/// [`flatten`] for nodes the caller no longer needs: the nodes are consumed
161/// and their paths, maps, and attribute values move into the result instead
162/// of being copied.
163///
164/// The result equals `flatten(&nodes)`.
165///
166/// ```
167/// use openbim_ifcx::{flatten, flatten_owned, IfcxFile};
168///
169/// let file = IfcxFile::from_json_str(r#"{
170///     "header": {"id": "demo", "ifcxVersion": "ifcx_alpha", "dataVersion": "1.0.0",
171///                "author": "someone", "timestamp": "2026-10-03"},
172///     "imports": [], "schemas": {},
173///     "data": [{"path": "w", "attributes": {"x::height": 2.5}},
174///              {"path": "w", "attributes": {"x::height": 3.0}}]
175/// }"#)?;
176/// let borrowed = flatten(&file.data);
177/// let owned = flatten_owned(file.data);
178/// assert_eq!(owned, borrowed);
179/// assert_eq!(*owned["w"].attributes["x::height"], 3.0);
180/// # Ok::<(), openbim_ifcx::ReadError>(())
181/// ```
182pub fn flatten_owned<I>(nodes: I) -> IndexMap<String, FlatNode>
183where
184    I: IntoIterator<Item = IfcxNode>,
185{
186    let mut flat: IndexMap<String, FlatNode> = IndexMap::new();
187    for node in nodes {
188        let IfcxNode {
189            path,
190            children,
191            inherits,
192            attributes,
193            ..
194        } = node;
195        flat.entry(path)
196            .or_default()
197            .merge_parts(children, inherits, attributes);
198    }
199    flat
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use serde_json::json;
206
207    fn node(value: Value) -> IfcxNode {
208        serde_json::from_value(value).unwrap()
209    }
210
211    fn keys<V>(map: &IndexMap<String, V>) -> Vec<&str> {
212        map.keys().map(String::as_str).collect()
213    }
214
215    #[test]
216    fn later_entries_replace_in_place() {
217        let nodes = [
218            node(json!({"path": "a", "children": {"x": "1", "y": "2"},
219                        "attributes": {"p": 1, "q": 2}})),
220            node(json!({"path": "a", "children": {"x": "3"}, "attributes": {"p": 4}})),
221        ];
222        let flat = flatten(&nodes);
223        assert_eq!(flatten_owned(nodes.clone()), flat);
224        let a = &flat["a"];
225        assert_eq!(keys(&a.children), ["x", "y"]);
226        assert_eq!(a.children["x"].as_deref(), Some("3"));
227        assert_eq!(keys(&a.attributes), ["p", "q"]);
228        assert_eq!(*a.attributes["p"], json!(4));
229    }
230
231    #[test]
232    fn null_inherit_removes_and_readd_moves_to_end() {
233        let nodes = [
234            node(json!({"path": "a", "inherits": {"t": "T", "u": "U"}})),
235            node(json!({"path": "a", "inherits": {"t": null}})),
236            node(json!({"path": "a", "inherits": {"t": "T2"}})),
237        ];
238        let flat = flatten(&nodes);
239        assert_eq!(flatten_owned(nodes.clone()), flat);
240        assert_eq!(keys(&flat["a"].inherits), ["u", "t"]);
241        assert_eq!(flat["a"].inherits["t"], "T2");
242    }
243
244    #[test]
245    fn null_child_and_null_attribute_are_kept() {
246        let nodes = [
247            node(json!({"path": "a", "children": {"x": "1"}, "attributes": {"p": 1}})),
248            node(json!({"path": "a", "children": {"x": null}, "attributes": {"p": null}})),
249        ];
250        let flat = flatten(&nodes);
251        assert_eq!(flatten_owned(nodes.clone()), flat);
252        assert_eq!(flat["a"].children["x"], None);
253        assert_eq!(*flat["a"].attributes["p"], Value::Null);
254    }
255
256    #[test]
257    fn paths_keep_first_appearance_order() {
258        let nodes = [
259            node(json!({"path": "b"})),
260            node(json!({"path": "a/x"})),
261            node(json!({"path": "a"})),
262            node(json!({"path": "b", "attributes": {"p": 1}})),
263        ];
264        let flat = flatten(&nodes);
265        assert_eq!(flatten_owned(nodes.clone()), flat);
266        assert_eq!(keys(&flat), ["b", "a/x", "a"]);
267        assert_eq!(flat["a"], FlatNode::default());
268    }
269}