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}