openbim_ifcx_geometry/
glb.rs

1//! Binary glTF 2.0 (GLB) export of a [`RenderScene`].
2//!
3//! [`to_glb`] writes one self-contained `.glb` file that any glTF viewer
4//! opens. The JSON chunk is built with `serde_json`; no glTF library is
5//! used.
6//!
7//! # Layout
8//!
9//! - One root node, named `IFCX`, holds the scene's [`origin`] as its
10//!   translation and, by default, the rotation from IFCX's Z-up to glTF's
11//!   Y-up axes. Its `extras` record the origin in the file's coordinates
12//!   (`ifcxOrigin`) and the up axis written (`ifcxUpAxis`).
13//! - Each [`Instance`] becomes one child node of the root, named by the
14//!   instance path, with the instance's render matrix and `extras.ifcxNode`
15//!   set to [`Instance::node`]. A node with several kinds of geometry gives
16//!   one glTF node per kind, all with the same name.
17//! - Each pair of geometry buffer and material becomes one glTF mesh, which
18//!   every instance of that pair references. Each buffer is written once, so
19//!   instances share vertex data even across materials.
20//! - Meshes are `TRIANGLES` with `POSITION`, `NORMAL`, and `u32` indices;
21//!   lines are `LINES` with indices; points are `POINTS` with `COLOR_0` when
22//!   they have colours. `POSITION` accessors carry `min` and `max`.
23//! - Materials used by an instance map onto `pbrMetallicRoughness`: a
24//!   [`BasicMaterial`](crate::BasicMaterial) is its colour with opacity as
25//!   alpha, metallic 0, roughness 1, and `BLEND` when not opaque; a
26//!   [`GltfMaterial`] keeps its factors, `alphaMode`, `alphaCutoff`, and
27//!   `doubleSided`. Texture references are not written, since the scene does
28//!   not load them. Factors and colours are clamped to `0..=1`, as glTF
29//!   requires, and `alphaCutoff` to a finite number of at least 0 (`0.5`
30//!   for NaN). With the scene's own finite coordinates (see
31//!   [`SceneWarningKind::OutOfRange`](crate::SceneWarningKind::OutOfRange)),
32//!   every number in the JSON chunk is finite, so it never holds `null`.
33//! - Every buffer view starts on a 4-byte boundary, and both chunks are
34//!   padded to 4 bytes, the JSON chunk with spaces.
35//!
36//! [`origin`]: RenderScene::origin
37
38use std::collections::HashMap;
39use std::fmt;
40
41use serde_json::{json, Map, Value};
42
43use crate::presentation::{AlphaMode, GltfMaterial, Material};
44use crate::scene::{GeometryRef, Instance, RenderScene};
45
46/// Options for [`to_glb`].
47#[derive(Debug, Clone, PartialEq)]
48#[non_exhaustive]
49pub struct GlbOptions {
50    /// Rotate the root from IFCX's Z-up to glTF's Y-up axes, so the model
51    /// stands upright in glTF viewers. Default `true`.
52    pub y_up: bool,
53    /// Put the scene origin on the root node's translation, so the file
54    /// keeps the model's coordinates. With `false` the model sits around
55    /// the glTF origin and the origin is only recorded in `extras`. Default
56    /// `true`.
57    pub origin_on_root: bool,
58}
59
60impl Default for GlbOptions {
61    fn default() -> Self {
62        Self {
63            y_up: true,
64            origin_on_root: true,
65        }
66    }
67}
68
69impl GlbOptions {
70    /// Sets [`y_up`](Self::y_up).
71    pub fn with_y_up(mut self, y_up: bool) -> Self {
72        self.y_up = y_up;
73        self
74    }
75
76    /// Sets [`origin_on_root`](Self::origin_on_root).
77    pub fn with_origin_on_root(mut self, origin_on_root: bool) -> Self {
78        self.origin_on_root = origin_on_root;
79        self
80    }
81}
82
83/// Why a scene could not be written as GLB.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum GlbError {
87    /// The file would exceed the 4 GiB that GLB's 32-bit lengths allow.
88    TooLarge { bytes: u64 },
89}
90
91impl fmt::Display for GlbError {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::TooLarge { bytes } => {
95                write!(f, "GLB would be {bytes} bytes, more than 4 GiB")
96            }
97        }
98    }
99}
100
101impl std::error::Error for GlbError {}
102
103const ARRAY_BUFFER: u32 = 34962;
104const ELEMENT_ARRAY_BUFFER: u32 = 34963;
105const FLOAT: u32 = 5126;
106const UNSIGNED_INT: u32 = 5125;
107const POINTS: u32 = 0;
108const LINES: u32 = 1;
109const TRIANGLES: u32 = 4;
110
111/// -90° about X: IFCX `(x, y, z)` to glTF `(x, z, -y)`.
112const Z_UP_TO_Y_UP: [f64; 4] = [
113    -std::f64::consts::FRAC_1_SQRT_2,
114    0.0,
115    0.0,
116    std::f64::consts::FRAC_1_SQRT_2,
117];
118
119/// Writes `scene` as a binary glTF 2.0 file.
120///
121/// ```
122/// use openbim_ifcx::{compose, flatten, IfcxNode};
123/// use openbim_ifcx_geometry::glb::{to_glb, GlbOptions};
124/// use openbim_ifcx_geometry::scene::{RenderScene, SceneOptions};
125///
126/// let nodes: Vec<IfcxNode> = serde_json::from_str(r#"[
127///     {"path": "panel", "attributes": {"usd::usdgeom::mesh": {
128///         "points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], "faceVertexIndices": [0, 1, 2]}}}
129/// ]"#)?;
130/// let scene = RenderScene::from_composition(&compose(&flatten(&nodes))?, &SceneOptions::default());
131/// let glb = to_glb(&scene, &GlbOptions::default())?;
132/// assert_eq!(&glb[..4], b"glTF");
133/// assert_eq!(glb.len() % 4, 0);
134/// # Ok::<(), Box<dyn std::error::Error>>(())
135/// ```
136pub fn to_glb(scene: &RenderScene, options: &GlbOptions) -> Result<Vec<u8>, GlbError> {
137    let mut writer = Writer::default();
138    let json = writer.document(scene, options);
139    let mut json = serde_json::to_vec(&json).expect("JSON values always serialize");
140    pad(&mut json, b' ');
141    let bin = writer.bin;
142
143    let bin_chunk = if bin.is_empty() {
144        0
145    } else {
146        8 + bin.len() as u64
147    };
148    let total = 12 + 8 + json.len() as u64 + bin_chunk;
149    let total = u32::try_from(total).map_err(|_| GlbError::TooLarge { bytes: total })?;
150
151    let mut out = Vec::with_capacity(total as usize);
152    out.extend_from_slice(b"glTF");
153    out.extend_from_slice(&2u32.to_le_bytes());
154    out.extend_from_slice(&total.to_le_bytes());
155    out.extend_from_slice(&(json.len() as u32).to_le_bytes());
156    out.extend_from_slice(b"JSON");
157    out.extend_from_slice(&json);
158    if !bin.is_empty() {
159        out.extend_from_slice(&(bin.len() as u32).to_le_bytes());
160        out.extend_from_slice(b"BIN\0");
161        out.extend_from_slice(&bin);
162    }
163    Ok(out)
164}
165
166fn pad(bytes: &mut Vec<u8>, with: u8) {
167    while bytes.len() % 4 != 0 {
168        bytes.push(with);
169    }
170}
171
172/// An `f32` as the JSON number of its exact value, so readers that parse
173/// `f64` and round to `f32` get the same bits back.
174fn num(v: f32) -> Value {
175    json!(f64::from(v))
176}
177
178fn clamp01(v: f32) -> f32 {
179    if v.is_nan() {
180        0.0
181    } else {
182        v.clamp(0.0, 1.0)
183    }
184}
185
186/// Accessor indices of one written buffer.
187#[derive(Clone, Copy)]
188struct Written {
189    position: usize,
190    normal: Option<usize>,
191    color: Option<usize>,
192    indices: Option<usize>,
193    mode: u32,
194}
195
196#[derive(Default)]
197struct Writer {
198    bin: Vec<u8>,
199    views: Vec<Value>,
200    accessors: Vec<Value>,
201}
202
203impl Writer {
204    fn view(&mut self, bytes: impl IntoIterator<Item = [u8; 4]>, target: u32) -> usize {
205        pad(&mut self.bin, 0);
206        let offset = self.bin.len();
207        for b in bytes {
208            self.bin.extend_from_slice(&b);
209        }
210        self.views.push(json!({
211            "buffer": 0,
212            "byteOffset": offset,
213            "byteLength": self.bin.len() - offset,
214            "target": target,
215        }));
216        self.views.len() - 1
217    }
218
219    fn vec3(&mut self, values: &[[f32; 3]], min_max: bool) -> usize {
220        let view = self.view(
221            values.iter().flatten().map(|v| v.to_le_bytes()),
222            ARRAY_BUFFER,
223        );
224        let mut accessor = json!({
225            "bufferView": view,
226            "componentType": FLOAT,
227            "count": values.len(),
228            "type": "VEC3",
229        });
230        if min_max {
231            let mut min = [f32::INFINITY; 3];
232            let mut max = [f32::NEG_INFINITY; 3];
233            for v in values {
234                for k in 0..3 {
235                    min[k] = min[k].min(v[k]);
236                    max[k] = max[k].max(v[k]);
237                }
238            }
239            accessor["min"] = json!(min.map(num));
240            accessor["max"] = json!(max.map(num));
241        }
242        self.accessors.push(accessor);
243        self.accessors.len() - 1
244    }
245
246    fn indices(&mut self, indices: &[u32]) -> usize {
247        let view = self.view(
248            indices.iter().map(|i| i.to_le_bytes()),
249            ELEMENT_ARRAY_BUFFER,
250        );
251        self.accessors.push(json!({
252            "bufferView": view,
253            "componentType": UNSIGNED_INT,
254            "count": indices.len(),
255            "type": "SCALAR",
256        }));
257        self.accessors.len() - 1
258    }
259
260    fn geometry(&mut self, scene: &RenderScene, geometry: GeometryRef) -> Written {
261        match geometry {
262            GeometryRef::Mesh(i) => {
263                let m = &scene.meshes[i];
264                Written {
265                    position: self.vec3(&m.positions, true),
266                    normal: Some(self.vec3(&m.normals, false)),
267                    color: None,
268                    indices: Some(self.indices(&m.indices)),
269                    mode: TRIANGLES,
270                }
271            }
272            GeometryRef::Lines(i) => {
273                let l = &scene.lines[i];
274                Written {
275                    position: self.vec3(&l.positions, true),
276                    normal: None,
277                    color: None,
278                    indices: Some(self.indices(&l.indices)),
279                    mode: LINES,
280                }
281            }
282            GeometryRef::Points(i) => {
283                let p = &scene.points[i];
284                let colors = p.colors.as_ref().map(|colors| {
285                    let clamped: Vec<[f32; 3]> = colors.iter().map(|c| c.map(clamp01)).collect();
286                    self.vec3(&clamped, false)
287                });
288                Written {
289                    position: self.vec3(&p.positions, true),
290                    normal: None,
291                    color: colors,
292                    indices: None,
293                    mode: POINTS,
294                }
295            }
296        }
297    }
298
299    fn document(&mut self, scene: &RenderScene, options: &GlbOptions) -> Value {
300        let mut written: HashMap<GeometryRef, Written> = HashMap::new();
301        let mut meshes: Vec<Value> = Vec::new();
302        let mut mesh_ids: HashMap<(GeometryRef, usize), usize> = HashMap::new();
303        // Only materials some instance uses, in order of first use.
304        let mut materials: Vec<Value> = Vec::new();
305        let mut material_ids: HashMap<usize, usize> = HashMap::new();
306        let mut nodes: Vec<Value> = vec![Value::Null];
307        for instance in &scene.instances {
308            let w = *written
309                .entry(instance.geometry)
310                .or_insert_with(|| self.geometry(scene, instance.geometry));
311            let mesh = *mesh_ids
312                .entry((instance.geometry, instance.material))
313                .or_insert_with(|| {
314                    let material_id = *material_ids.entry(instance.material).or_insert_with(|| {
315                        materials.push(material(&scene.materials[instance.material]));
316                        materials.len() - 1
317                    });
318                    meshes.push(mesh(&w, material_id));
319                    meshes.len() - 1
320                });
321            nodes.push(instance_node(instance, mesh));
322        }
323
324        let mut root = Map::new();
325        root.insert("name".into(), json!("IFCX"));
326        if nodes.len() > 1 {
327            root.insert(
328                "children".into(),
329                json!((1..nodes.len()).collect::<Vec<_>>()),
330            );
331        }
332        let o = scene.origin;
333        let translation = match (options.origin_on_root, options.y_up) {
334            (false, _) => [0.0; 3],
335            (true, false) => o,
336            (true, true) => [o[0], o[2], -o[1]],
337        };
338        if translation != [0.0; 3] {
339            root.insert("translation".into(), json!(translation));
340        }
341        if options.y_up {
342            root.insert("rotation".into(), json!(Z_UP_TO_Y_UP));
343        }
344        root.insert(
345            "extras".into(),
346            json!({"ifcxOrigin": o, "ifcxUpAxis": if options.y_up { "Y" } else { "Z" }}),
347        );
348        nodes[0] = Value::Object(root);
349
350        let mut document = json!({
351            "asset": {
352                "version": "2.0",
353                "generator": concat!("openbim-ifcx-geometry ", env!("CARGO_PKG_VERSION")),
354            },
355            "scene": 0,
356            "scenes": [{"name": "IFCX", "nodes": [0]}],
357            "nodes": nodes,
358        });
359        if !meshes.is_empty() {
360            document["meshes"] = Value::Array(meshes);
361            document["materials"] = Value::Array(materials);
362            document["accessors"] = Value::Array(std::mem::take(&mut self.accessors));
363            document["bufferViews"] = Value::Array(std::mem::take(&mut self.views));
364            pad(&mut self.bin, 0);
365            document["buffers"] = json!([{"byteLength": self.bin.len()}]);
366        }
367        document
368    }
369}
370
371fn mesh(w: &Written, material: usize) -> Value {
372    let mut attributes = Map::new();
373    attributes.insert("POSITION".into(), json!(w.position));
374    if let Some(n) = w.normal {
375        attributes.insert("NORMAL".into(), json!(n));
376    }
377    if let Some(c) = w.color {
378        attributes.insert("COLOR_0".into(), json!(c));
379    }
380    let mut primitive = json!({"attributes": attributes, "material": material, "mode": w.mode});
381    if let Some(i) = w.indices {
382        primitive["indices"] = json!(i);
383    }
384    json!({"primitives": [primitive]})
385}
386
387fn instance_node(instance: &Instance, mesh: usize) -> Value {
388    let mut node = json!({
389        "name": instance.path,
390        "mesh": mesh,
391        "extras": {"ifcxNode": instance.node},
392    });
393    const IDENTITY: [f32; 16] = [
394        1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
395    ];
396    if instance.matrix != IDENTITY {
397        node["matrix"] = json!(instance.matrix.map(num));
398    }
399    node
400}
401
402fn material(material: &Material) -> Value {
403    match material {
404        Material::Basic(basic) => {
405            let [r, g, b] = basic.color.map(clamp01);
406            let a = clamp01(basic.opacity);
407            let mut m = json!({
408                "pbrMetallicRoughness": {
409                    "baseColorFactor": [num(r), num(g), num(b), num(a)],
410                    "metallicFactor": 0.0,
411                    "roughnessFactor": 1.0,
412                },
413            });
414            if a < 1.0 {
415                m["alphaMode"] = json!("BLEND");
416            }
417            m
418        }
419        Material::Pbr(pbr) => pbr_material(pbr),
420    }
421}
422
423fn pbr_material(pbr: &GltfMaterial) -> Value {
424    let mut m = json!({
425        "pbrMetallicRoughness": {
426            "baseColorFactor": pbr.base_color_factor.map(clamp01).map(num),
427            "metallicFactor": num(clamp01(pbr.metallic_factor)),
428            "roughnessFactor": num(clamp01(pbr.roughness_factor)),
429        },
430    });
431    if pbr.emissive_factor != [0.0; 3] {
432        m["emissiveFactor"] = json!(pbr.emissive_factor.map(clamp01).map(num));
433    }
434    match pbr.alpha_mode {
435        AlphaMode::Opaque => {}
436        AlphaMode::Blend => m["alphaMode"] = json!("BLEND"),
437        AlphaMode::Mask => {
438            m["alphaMode"] = json!("MASK");
439            // glTF wants a finite number >= 0; NaN means the default.
440            let cutoff = if pbr.alpha_cutoff.is_nan() {
441                0.5
442            } else {
443                pbr.alpha_cutoff.clamp(0.0, f32::MAX)
444            };
445            m["alphaCutoff"] = num(cutoff);
446        }
447    }
448    if pbr.double_sided {
449        m["doubleSided"] = json!(true);
450    }
451    m
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn chunks(glb: &[u8]) -> (Value, &[u8]) {
459        let u32_at = |i: usize| u32::from_le_bytes(glb[i..i + 4].try_into().unwrap()) as usize;
460        assert_eq!(&glb[0..4], b"glTF");
461        assert_eq!(u32_at(4), 2);
462        assert_eq!(u32_at(8), glb.len());
463        let json_len = u32_at(12);
464        assert_eq!(&glb[16..20], b"JSON");
465        let json = serde_json::from_slice(&glb[20..20 + json_len]).unwrap();
466        let rest = &glb[20 + json_len..];
467        if rest.is_empty() {
468            return (json, rest);
469        }
470        assert_eq!(&rest[4..8], b"BIN\0");
471        let bin_len = u32::from_le_bytes(rest[0..4].try_into().unwrap()) as usize;
472        assert_eq!(rest.len(), 8 + bin_len);
473        (json, &rest[8..])
474    }
475
476    #[test]
477    fn empty_scene_has_only_a_root() {
478        let glb = to_glb(&RenderScene::default(), &GlbOptions::default()).unwrap();
479        assert_eq!(glb.len() % 4, 0);
480        let (json, bin) = chunks(&glb);
481        assert!(bin.is_empty());
482        assert_eq!(json["nodes"].as_array().unwrap().len(), 1);
483        assert!(json.get("buffers").is_none());
484        assert!(json["nodes"][0].get("translation").is_none());
485    }
486
487    #[test]
488    fn clamps_out_of_range_factors() {
489        let m = material(&Material::Basic(crate::BasicMaterial {
490            color: [1.5, -0.2, 0.5],
491            opacity: 0.25,
492        }));
493        assert_eq!(
494            m["pbrMetallicRoughness"]["baseColorFactor"],
495            json!([1.0, 0.0, 0.5, 0.25])
496        );
497        assert_eq!(m["alphaMode"], "BLEND");
498    }
499
500    /// Found by fuzzing (#41, target `scene_glb`): an infinite cutoff was
501    /// written as JSON `null`.
502    #[test]
503    fn alpha_cutoff_is_always_a_finite_number() {
504        for (cutoff, written) in [
505            (f32::INFINITY, f64::from(f32::MAX)),
506            (f32::NEG_INFINITY, 0.0),
507            (f32::NAN, 0.5),
508            (0.25, 0.25),
509        ] {
510            let m = pbr_material(&GltfMaterial {
511                alpha_mode: AlphaMode::Mask,
512                alpha_cutoff: cutoff,
513                ..GltfMaterial::default()
514            });
515            assert_eq!(m["alphaCutoff"].as_f64(), Some(written), "{cutoff}");
516        }
517    }
518
519    #[test]
520    fn exact_f32_numbers() {
521        assert_eq!(num(0.1).as_f64().unwrap() as f32, 0.1f32);
522        assert_eq!(num(0.1).as_f64().unwrap(), f64::from(0.1f32));
523    }
524}