openbim_ifcx_binding_core/
layers.rs

1//! Several layers composed into one model.
2//!
3//! A [`LayerSet`] holds the layers a host passed, weakest first, and
4//! optionally the files their `imports` name. Without imports, the layers'
5//! schemas and data are concatenated in order (upstream's `Federate`), as the
6//! `compose-json` example of `openbim-ifcx` does: the last layer wins.
7//!
8//! With imports, the layers are stacked as the imports of a main layer
9//! without data, as upstream's `ifcx compose` builds it
10//! (`LayerStackBuilder::build_all`), and resolved through a
11//! [`MemoryResolver`] holding the given files under their exact import
12//! `uri`. Every layer comes after the layers it imports and overrides them,
13//! as agreed upstream in buildingSMART/IFC5-development#144, and the next
14//! layer overrides both. Every import must then be present; `integrity`
15//! values are checked against the given bytes.
16
17use openbim_ifcx::layers::{federate_owned, LayerStackBuilder, MemoryResolver};
18use openbim_ifcx::{compose, flatten_owned, Composition, IfcxFile};
19use openbim_ifcx_geometry::glb::{to_glb, GlbOptions};
20use openbim_ifcx_geometry::scene::{RenderScene, SceneOptions};
21
22use crate::error::BindingError;
23use crate::{report, tree};
24
25/// Key of the `index`th layer passed by the host.
26fn layer_key(index: usize) -> String {
27    format!("<layer {index}>")
28}
29
30/// How [`LayerSet::to_glb`] places the model; see
31/// `openbim_ifcx_geometry::glb::GlbOptions`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct GlbSettings {
34    /// Rotate IFCX's Z-up axes to glTF's Y-up, so the model stands upright
35    /// in glTF viewers. Default `true`.
36    pub y_up: bool,
37    /// Keep the model's coordinates by putting the scene origin on the root
38    /// node. With `false` the model sits around the glTF origin, and the
39    /// origin is only recorded in the root's `extras`. Default `true`.
40    pub origin_on_root: bool,
41}
42
43impl Default for GlbSettings {
44    fn default() -> Self {
45        Self {
46            y_up: true,
47            origin_on_root: true,
48        }
49    }
50}
51
52/// The layers of one model and, optionally, the files they import.
53#[derive(Debug, Clone, Default)]
54pub struct LayerSet {
55    layers: Vec<Vec<u8>>,
56    imports: Option<Vec<(String, Vec<u8>)>>,
57}
58
59impl LayerSet {
60    /// An empty set that does not resolve imports.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Adds a layer's UTF-8 JSON bytes on top of the layers already added.
66    pub fn push_layer(&mut self, bytes: Vec<u8>) {
67        self.layers.push(bytes);
68    }
69
70    /// Resolves imports, even if no import file is added: every import of
71    /// every layer must then be one of the added files.
72    pub fn resolve_imports(&mut self) {
73        self.imports.get_or_insert_with(Vec::new);
74    }
75
76    /// Serves `bytes` for imports whose `uri` is exactly `uri`, and turns on
77    /// import resolution.
78    pub fn add_import(&mut self, uri: String, bytes: Vec<u8>) {
79        self.imports.get_or_insert_with(Vec::new).push((uri, bytes));
80    }
81
82    /// Number of layers added.
83    pub fn len(&self) -> usize {
84        self.layers.len()
85    }
86
87    /// Whether no layer was added.
88    pub fn is_empty(&self) -> bool {
89        self.layers.is_empty()
90    }
91
92    /// All layers merged into one file. Without imports, the layers' schemas
93    /// and data are concatenated in order. With imports, the layers are the
94    /// imports of a synthetic main layer, each after the layers it imports,
95    /// so a layer overrides its imports and the next layer overrides both.
96    /// The header is that of the last layer in this order, the strongest.
97    pub fn federate(&self) -> Result<IfcxFile, BindingError> {
98        if self.layers.is_empty() {
99            return Err(BindingError::InvalidArgument(
100                "at least one layer is needed".into(),
101            ));
102        }
103        match &self.imports {
104            None => {
105                let files = self
106                    .layers
107                    .iter()
108                    .enumerate()
109                    .map(|(index, bytes)| {
110                        IfcxFile::from_json_slice(bytes)
111                            .map_err(|e| BindingError::Read(format!("layer {index}: {e}")))
112                    })
113                    .collect::<Result<Vec<_>, _>>()?;
114                Ok(federate_owned(files).expect("at least one layer"))
115            }
116            Some(imports) => self.federate_stack(imports),
117        }
118    }
119
120    fn federate_stack(&self, imports: &[(String, Vec<u8>)]) -> Result<IfcxFile, BindingError> {
121        let mut resolver = MemoryResolver::new();
122        for (uri, bytes) in imports {
123            resolver.insert(uri.clone(), bytes.clone());
124        }
125        let mut keys = Vec::with_capacity(self.layers.len());
126        for (index, bytes) in self.layers.iter().enumerate() {
127            // Read each layer here as well, so a malformed one is a `read`
128            // error naming its index, not a `layer` error naming a key the
129            // host never saw.
130            IfcxFile::from_json_slice(bytes)
131                .map_err(|e| BindingError::Read(format!("layer {index}: {e}")))?;
132            resolver.insert(layer_key(index), bytes.clone());
133            keys.push(layer_key(index));
134        }
135        let stack = LayerStackBuilder::new(resolver)
136            .build_all(&keys)
137            .map_err(|e| BindingError::Layer(e.to_string()))?;
138        // The strongest layer's header leads, as without imports.
139        Ok(stack.into_federated())
140    }
141
142    /// Flattens and composes the federated layers.
143    pub fn compose(&self) -> Result<Composition, BindingError> {
144        let file = self.federate()?;
145        compose(&flatten_owned(file.data)).map_err(|e| BindingError::Compose(e.to_string()))
146    }
147
148    /// The composed tree as JSON text: the artificial root over every root
149    /// node, each node `{"path", "attributes", "children"}`.
150    pub fn compose_json(&self) -> Result<String, BindingError> {
151        let composition = self.compose()?;
152        serde_json::to_string(&tree::Tree(&composition.root()))
153            .map_err(|e| BindingError::Write(e.to_string()))
154    }
155
156    /// The composed layers as a binary glTF 2.0 (`.glb`) file: their flat
157    /// render scene with transformed, instanced meshes, lines and points and
158    /// their materials, one glTF node per instance named by its IFCX path.
159    ///
160    /// Geometry values that do not decode are left out of the scene, as
161    /// `RenderScene` does; the file is still written.
162    pub fn to_glb(&self, settings: &GlbSettings) -> Result<Vec<u8>, BindingError> {
163        let composition = self.compose()?;
164        let scene = RenderScene::from_composition(&composition, &SceneOptions::default());
165        let options = GlbOptions::default()
166            .with_y_up(settings.y_up)
167            .with_origin_on_root(settings.origin_on_root);
168        to_glb(&scene, &options).map_err(|e| BindingError::Glb(e.to_string()))
169    }
170
171    /// Checks the attributes of the federated layers against their merged
172    /// `schemas`, those of resolved imports included (as
173    /// `openbim_ifcx::layers::LayerStack::validate` does), and returns the
174    /// report as JSON text:
175    /// `{"valid": bool, "failures": [{"node", "attribute", "pointer", "kind",
176    /// "message"}]}`. `kind` is a stable code such as `missing-schema` or
177    /// `type-mismatch`.
178    pub fn validate_json(&self) -> Result<String, BindingError> {
179        let file = self.federate()?;
180        Ok(report::to_json(&file.validate().err().unwrap_or_default()))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use serde_json::Value;
187
188    use super::{GlbSettings, LayerSet};
189    use crate::fixtures::fixture;
190
191    fn tree(layers: &LayerSet) -> Value {
192        serde_json::from_str(&layers.compose_json().unwrap()).unwrap()
193    }
194
195    fn set(layers: &[&[u8]]) -> LayerSet {
196        let mut set = LayerSet::new();
197        for layer in layers {
198            set.push_layer(layer.to_vec());
199        }
200        set
201    }
202
203    #[test]
204    fn one_layer_composes_to_the_upstream_tree() {
205        let composed = tree(&set(&[fixture!("occurrence-type.ifcx")]));
206        let expected: Value =
207            serde_json::from_slice(fixture!("occurrence-type.composed.json")).unwrap();
208        assert_eq!(composed, expected);
209    }
210
211    /// A layer that renames the roof of `geometry-model.ifcx` and, when
212    /// `imports` is not empty, imports those URIs.
213    fn overlay(imports: &[&str]) -> Vec<u8> {
214        let imports: Vec<String> = imports
215            .iter()
216            .map(|uri| format!(r#"{{"uri": "{uri}"}}"#))
217            .collect();
218        format!(
219            r#"{{
220                "header": {{"id": "overlay", "ifcxVersion": "ifcx_alpha", "dataVersion": "1.0.0",
221                           "author": "test", "timestamp": "2026-10-03"}},
222                "imports": [{}], "schemas": {{}},
223                "data": [{{"path": "roof", "attributes": {{"example::class": "Roof"}}}}]
224            }}"#,
225            imports.join(", ")
226        )
227        .into_bytes()
228    }
229
230    fn roof_class(tree: &Value) -> &Value {
231        &tree["children"]["pavilion"]["children"]["Storey"]["children"]["Roof"]["attributes"]
232            ["example::class"]
233    }
234
235    #[test]
236    fn the_last_layer_wins() {
237        let model = fixture!("geometry-model.ifcx");
238        assert_eq!(roof_class(&tree(&set(&[model]))), "Slab");
239        assert_eq!(roof_class(&tree(&set(&[model, &overlay(&[])]))), "Roof");
240        assert_eq!(roof_class(&tree(&set(&[&overlay(&[]), model]))), "Slab");
241        // The overlay's attribute matches the model's schema.
242        let report: Value =
243            serde_json::from_str(&set(&[model, &overlay(&[])]).validate_json().unwrap()).unwrap();
244        assert_eq!(report["valid"], true, "{report}");
245    }
246
247    #[test]
248    fn imports_resolve_from_memory_and_the_importer_overrides_them() {
249        // A layer overrides the layers it imports
250        // (buildingSMART/IFC5-development#144).
251        let mut layers = set(&[&overlay(&["model.ifcx"])]);
252        layers.add_import(
253            "model.ifcx".into(),
254            fixture!("geometry-model.ifcx").to_vec(),
255        );
256        assert_eq!(roof_class(&tree(&layers)), "Roof");
257
258        // A later layer overrides an earlier one and everything it imports.
259        let mut layers = set(&[&overlay(&["model.ifcx"]), fixture!("geometry-model.ifcx")]);
260        layers.add_import(
261            "model.ifcx".into(),
262            fixture!("geometry-model.ifcx").to_vec(),
263        );
264        // Named as a layer of its own, the model is a different layer from
265        // the import and, coming last, wins.
266        assert_eq!(roof_class(&tree(&layers)), "Slab");
267        let mut layers = set(&[&overlay(&["model.ifcx"]), &overlay(&[])]);
268        layers.add_import(
269            "model.ifcx".into(),
270            fixture!("geometry-model.ifcx").to_vec(),
271        );
272        assert_eq!(roof_class(&tree(&layers)), "Roof");
273        // The imported schemas validate the overlay's attribute.
274        let report: Value = serde_json::from_str(&layers.validate_json().unwrap()).unwrap();
275        assert_eq!(report["valid"], true, "{report}");
276    }
277
278    #[test]
279    fn a_chain_resolves_with_integrity_and_the_main_layer_wins() {
280        let mut layers = set(&[fixture!("layers/chain/main.ifcx")]);
281        layers.add_import(
282            "mid.ifcx".into(),
283            fixture!("layers/chain/mid.ifcx").to_vec(),
284        );
285        layers.add_import(
286            "sub/base.ifcx".into(),
287            fixture!("layers/chain/sub/base.ifcx").to_vec(),
288        );
289        let file = layers.federate().unwrap();
290        assert_eq!(file.header.id, "openbimrs/ifcx/fixtures/layers/chain/main");
291        let ids = |name: &str| -> usize {
292            let layer: Value = serde_json::from_slice(match name {
293                "main" => fixture!("layers/chain/main.ifcx"),
294                "mid" => fixture!("layers/chain/mid.ifcx"),
295                _ => fixture!("layers/chain/sub/base.ifcx"),
296            })
297            .unwrap();
298            layer["data"].as_array().unwrap().len()
299        };
300        assert_eq!(file.data.len(), ids("main") + ids("mid") + ids("base"));
301        // `main`'s data comes last and wins.
302        assert_eq!(file.data.last().unwrap().path, "site");
303        // Schemas from every layer of the stack are merged for validation.
304        let report: Value = serde_json::from_str(&layers.validate_json().unwrap()).unwrap();
305        assert_eq!(report["valid"], true, "{report}");
306
307        // Without imports, only the given layer's data and schemas count.
308        let alone = set(&[fixture!("layers/chain/main.ifcx")])
309            .federate()
310            .unwrap();
311        assert_eq!(alone.data.len(), ids("main"));
312    }
313
314    #[test]
315    fn a_missing_import_or_a_wrong_integrity_is_a_layer_error() {
316        let mut layers = set(&[fixture!("layers/chain/main.ifcx")]);
317        layers.resolve_imports();
318        let error = layers.compose_json().unwrap_err();
319        assert_eq!(error.code(), "layer");
320        assert!(error.to_string().contains("mid.ifcx"), "{error}");
321
322        let mut layers = set(&[fixture!("layers/chain/mid.ifcx")]);
323        layers.add_import(
324            "sub/base.ifcx".into(),
325            fixture!("layers/chain/main.ifcx").to_vec(),
326        );
327        let error = layers.compose_json().unwrap_err();
328        assert_eq!(error.code(), "layer");
329        assert!(error.to_string().contains("integrity"), "{error}");
330    }
331
332    #[test]
333    fn refusals_carry_stable_codes() {
334        assert_eq!(
335            LayerSet::new().compose_json().unwrap_err().code(),
336            "invalid-argument"
337        );
338        let error = set(&[fixture!("minimal.ifcx"), b"[]"])
339            .compose_json()
340            .unwrap_err();
341        assert_eq!(error.code(), "read");
342        assert!(error.to_string().starts_with("layer 1:"), "{error}");
343        let mut with_imports = set(&[b"nope"]);
344        with_imports.resolve_imports();
345        assert_eq!(with_imports.compose_json().unwrap_err().code(), "read");
346        // layer-edit alone references nodes only layer-base defines.
347        let error = set(&[fixture!("layer-edit.ifcx")])
348            .compose_json()
349            .unwrap_err();
350        assert_eq!(error.code(), "compose");
351    }
352
353    /// The JSON chunk of a GLB file, after checking the container.
354    fn glb_json(glb: &[u8]) -> Value {
355        assert_eq!(&glb[..4], b"glTF");
356        assert_eq!(u32::from_le_bytes(glb[4..8].try_into().unwrap()), 2);
357        assert_eq!(
358            u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize,
359            glb.len()
360        );
361        assert_eq!(glb.len() % 4, 0);
362        let len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
363        assert_eq!(&glb[16..20], b"JSON");
364        serde_json::from_slice(&glb[20..20 + len]).unwrap()
365    }
366
367    #[test]
368    fn layers_export_as_glb_with_one_node_per_instance() {
369        let model = fixture!("geometry-model.ifcx");
370        let glb = set(&[model]).to_glb(&GlbSettings::default()).unwrap();
371        let json = glb_json(&glb);
372        let names: Vec<&str> = json["nodes"]
373            .as_array()
374            .unwrap()
375            .iter()
376            .filter_map(|node| node["name"].as_str())
377            .collect();
378        assert!(
379            names.contains(&"pavilion/Storey/Column 1/Body"),
380            "{names:?}"
381        );
382        assert!(names.contains(&"pavilion/Storey/Roof"), "{names:?}");
383        // Column 2 hides its inherited axis.
384        assert!(
385            !names.contains(&"pavilion/Storey/Column 2/Axis"),
386            "{names:?}"
387        );
388        assert!(json["nodes"][0]["rotation"].is_array(), "Y-up by default");
389
390        let z_up = set(&[model])
391            .to_glb(&GlbSettings {
392                y_up: false,
393                origin_on_root: false,
394            })
395            .unwrap();
396        let root = &glb_json(&z_up)["nodes"][0];
397        assert!(root.get("rotation").is_none() && root.get("translation").is_none());
398
399        // An overlay layer changes the export like it changes the tree.
400        let hidden = br#"{
401            "header": {"id": "hide", "ifcxVersion": "ifcx_alpha", "dataVersion": "1.0.0",
402                       "author": "test", "timestamp": "2026-10-03"},
403            "imports": [], "schemas": {},
404            "data": [{"path": "roof", "attributes":
405                {"usd::usdgeom::visibility": {"visibility": "invisible"}}}]
406        }"#;
407        let json = glb_json(
408            &set(&[model, hidden])
409                .to_glb(&GlbSettings::default())
410                .unwrap(),
411        );
412        assert!(!json["nodes"]
413            .as_array()
414            .unwrap()
415            .iter()
416            .any(|node| node["name"] == "pavilion/Storey/Roof"));
417    }
418
419    #[test]
420    fn a_model_without_geometry_is_an_empty_glb_and_errors_keep_their_codes() {
421        glb_json(
422            &set(&[fixture!("minimal.ifcx")])
423                .to_glb(&GlbSettings::default())
424                .unwrap(),
425        );
426        let error = set(&[fixture!("layer-edit.ifcx")])
427            .to_glb(&GlbSettings::default())
428            .unwrap_err();
429        assert_eq!(error.code(), "compose");
430        assert_eq!(
431            LayerSet::new()
432                .to_glb(&GlbSettings::default())
433                .unwrap_err()
434                .code(),
435            "invalid-argument"
436        );
437    }
438
439    #[test]
440    fn federated_validation_merges_schemas_in_layer_order() {
441        let report: Value = serde_json::from_str(
442            &set(&[fixture!("invalid-attributes.ifcx")])
443                .validate_json()
444                .unwrap(),
445        )
446        .unwrap();
447        assert_eq!(report["valid"], false);
448        assert!(!report["failures"].as_array().unwrap().is_empty());
449    }
450}