openbim_ifcx_binding_core/
layers.rs1use 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
25fn layer_key(index: usize) -> String {
27 format!("<layer {index}>")
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct GlbSettings {
34 pub y_up: bool,
37 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#[derive(Debug, Clone, Default)]
54pub struct LayerSet {
55 layers: Vec<Vec<u8>>,
56 imports: Option<Vec<(String, Vec<u8>)>>,
57}
58
59impl LayerSet {
60 pub fn new() -> Self {
62 Self::default()
63 }
64
65 pub fn push_layer(&mut self, bytes: Vec<u8>) {
67 self.layers.push(bytes);
68 }
69
70 pub fn resolve_imports(&mut self) {
73 self.imports.get_or_insert_with(Vec::new);
74 }
75
76 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 pub fn len(&self) -> usize {
84 self.layers.len()
85 }
86
87 pub fn is_empty(&self) -> bool {
89 self.layers.is_empty()
90 }
91
92 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 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 Ok(stack.into_federated())
140 }
141
142 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 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 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 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 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 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 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 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 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 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 assert_eq!(file.data.last().unwrap().path, "site");
303 let report: Value = serde_json::from_str(&layers.validate_json().unwrap()).unwrap();
305 assert_eq!(report["valid"], true, "{report}");
306
307 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 let error = set(&[fixture!("layer-edit.ifcx")])
348 .compose_json()
349 .unwrap_err();
350 assert_eq!(error.code(), "compose");
351 }
352
353 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 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 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}