brepkit-io 3.2.15

Multi-format B-Rep data exchange for brepkit (STEP, IGES, STL, 3MF, OBJ, PLY, glTF)
Documentation
//! glTF 2.0 binary (.glb) writer.
//!
//! Produces a single-file GLB containing tessellated mesh geometry
//! with vertex positions, normals, and triangle indices.

use std::io::Write;

use brepkit_operations::tessellate;
use brepkit_topology::Topology;
use brepkit_topology::explorer::solid_faces;
use brepkit_topology::solid::SolidId;

/// Write one or more solids to glTF binary (.glb) format.
///
/// The output is a single GLB file containing a mesh with positions,
/// normals, and triangle indices. No materials or textures are included.
///
/// # Errors
///
/// Returns an error if tessellation fails.
#[allow(clippy::too_many_lines)]
pub fn write_glb(
    topo: &Topology,
    solids: &[SolidId],
    deflection: f64,
) -> Result<Vec<u8>, crate::IoError> {
    // Tessellate all solids into a merged mesh.
    let mut positions: Vec<f32> = Vec::new();
    let mut normals: Vec<f32> = Vec::new();
    let mut indices: Vec<u32> = Vec::new();

    let mut vertex_offset: u32 = 0;

    for &solid_id in solids {
        // Walk outer + inner (cavity) shells so hollow solids'
        // cavity surfaces are present in the GLB output.
        let face_ids = solid_faces(topo, solid_id)?;

        for &face_id in &face_ids {
            let mesh = tessellate::tessellate(topo, face_id, deflection)?;
            let offset = vertex_offset;

            for pos in &mesh.positions {
                #[allow(clippy::cast_possible_truncation)]
                {
                    positions.push(pos.x() as f32);
                    positions.push(pos.y() as f32);
                    positions.push(pos.z() as f32);
                }
            }

            for norm in &mesh.normals {
                #[allow(clippy::cast_possible_truncation)]
                {
                    normals.push(norm.x() as f32);
                    normals.push(norm.y() as f32);
                    normals.push(norm.z() as f32);
                }
            }

            for &idx in &mesh.indices {
                indices.push(idx + offset);
            }

            #[allow(clippy::cast_possible_truncation)]
            {
                vertex_offset += mesh.positions.len() as u32;
            }
        }
    }

    let vertex_count = positions.len() / 3;
    let index_count = indices.len();

    if vertex_count == 0 {
        return Err(crate::IoError::InvalidTopology {
            reason: "no vertices to export".into(),
        });
    }

    // Compute bounding box for accessor min/max.
    let mut min_pos = [f32::MAX; 3];
    let mut max_pos = [f32::MIN; 3];
    for chunk in positions.chunks_exact(3) {
        for i in 0..3 {
            min_pos[i] = min_pos[i].min(chunk[i]);
            max_pos[i] = max_pos[i].max(chunk[i]);
        }
    }

    // Build binary buffer: positions + normals + indices
    let pos_bytes = positions.len() * 4;
    let norm_bytes = normals.len() * 4;
    let idx_bytes = indices.len() * 4;
    let total_buffer = pos_bytes + norm_bytes + idx_bytes;

    let mut bin_buffer = Vec::with_capacity(total_buffer);
    for &val in &positions {
        bin_buffer.write_all(&val.to_le_bytes()).ok();
    }
    for &val in &normals {
        bin_buffer.write_all(&val.to_le_bytes()).ok();
    }
    for &val in &indices {
        bin_buffer.write_all(&val.to_le_bytes()).ok();
    }

    // Pad buffer to 4-byte alignment
    while bin_buffer.len() % 4 != 0 {
        bin_buffer.push(0);
    }

    let json = format!(
        r#"{{"asset":{{"version":"2.0","generator":"brepkit"}},"scene":0,"scenes":[{{"nodes":[0]}}],"nodes":[{{"mesh":0}}],"meshes":[{{"primitives":[{{"attributes":{{"POSITION":0,"NORMAL":1}},"indices":2}}]}}],"accessors":[{{"bufferView":0,"componentType":5126,"count":{vertex_count},"type":"VEC3","min":[{min0},{min1},{min2}],"max":[{max0},{max1},{max2}]}},{{"bufferView":1,"componentType":5126,"count":{vertex_count},"type":"VEC3"}},{{"bufferView":2,"componentType":5125,"count":{index_count},"type":"SCALAR"}}],"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":{pos_bytes}}},{{"buffer":0,"byteOffset":{norm_offset},"byteLength":{norm_bytes}}},{{"buffer":0,"byteOffset":{idx_offset},"byteLength":{idx_bytes}}}],"buffers":[{{"byteLength":{buf_len}}}]}}"#,
        vertex_count = vertex_count,
        index_count = index_count,
        min0 = min_pos[0],
        min1 = min_pos[1],
        min2 = min_pos[2],
        max0 = max_pos[0],
        max1 = max_pos[1],
        max2 = max_pos[2],
        pos_bytes = pos_bytes,
        norm_offset = pos_bytes,
        norm_bytes = norm_bytes,
        idx_offset = pos_bytes + norm_bytes,
        idx_bytes = idx_bytes,
        buf_len = bin_buffer.len(),
    );

    let mut json_bytes = json.into_bytes();
    // Pad JSON to 4-byte alignment with spaces
    while json_bytes.len() % 4 != 0 {
        json_bytes.push(b' ');
    }

    // GLB header: magic + version + total length
    let total_len = 12 + 8 + json_bytes.len() + 8 + bin_buffer.len();

    let mut glb = Vec::with_capacity(total_len);

    glb.write_all(&0x4654_6C67_u32.to_le_bytes()).ok(); // magic: "glTF"
    glb.write_all(&2_u32.to_le_bytes()).ok(); // version: 2
    #[allow(clippy::cast_possible_truncation)]
    {
        glb.write_all(&(total_len as u32).to_le_bytes()).ok(); // total length
    }

    // JSON chunk
    #[allow(clippy::cast_possible_truncation)]
    {
        glb.write_all(&(json_bytes.len() as u32).to_le_bytes()).ok(); // chunk length
    }
    glb.write_all(&0x4E4F_534A_u32.to_le_bytes()).ok(); // chunk type: "JSON"
    glb.write_all(&json_bytes).ok();

    // BIN chunk
    #[allow(clippy::cast_possible_truncation)]
    {
        glb.write_all(&(bin_buffer.len() as u32).to_le_bytes()).ok(); // chunk length
    }
    glb.write_all(&0x004E_4942_u32.to_le_bytes()).ok(); // chunk type: "BIN\0"
    glb.write_all(&bin_buffer).ok();

    Ok(glb)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use brepkit_topology::Topology;

    use super::*;

    #[test]
    fn write_box_glb() {
        let mut topo = Topology::new();
        let solid = brepkit_operations::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();

        let glb = write_glb(&topo, &[solid], 0.1).unwrap();

        // GLB starts with magic "glTF"
        assert_eq!(&glb[0..4], &0x4654_6C67_u32.to_le_bytes());

        // Version 2
        assert_eq!(&glb[4..8], &2_u32.to_le_bytes());

        // Total length matches actual length
        let total_len = u32::from_le_bytes([glb[8], glb[9], glb[10], glb[11]]);
        assert_eq!(total_len as usize, glb.len());
    }

    #[test]
    fn glb_has_json_and_bin_chunks() {
        let mut topo = Topology::new();
        let solid = brepkit_operations::primitives::make_box(&mut topo, 2.0, 3.0, 4.0).unwrap();

        let glb = write_glb(&topo, &[solid], 0.1).unwrap();

        // After 12-byte header: JSON chunk
        let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
        let json_type = u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]);
        assert_eq!(json_type, 0x4E4F_534A); // "JSON"

        let json_str = std::str::from_utf8(&glb[20..20 + json_len]).unwrap();
        assert!(json_str.contains("\"version\":\"2.0\""));
        assert!(json_str.contains("\"generator\":\"brepkit\""));
        assert!(json_str.contains("POSITION"));
        assert!(json_str.contains("NORMAL"));

        // BIN chunk follows JSON
        let bin_offset = 20 + json_len;
        let bin_type = u32::from_le_bytes([
            glb[bin_offset + 4],
            glb[bin_offset + 5],
            glb[bin_offset + 6],
            glb[bin_offset + 7],
        ]);
        assert_eq!(bin_type, 0x004E_4942); // "BIN\0"
    }

    #[test]
    fn glb_is_4byte_aligned() {
        let mut topo = Topology::new();
        let solid = brepkit_operations::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();

        let glb = write_glb(&topo, &[solid], 0.1).unwrap();

        assert_eq!(glb.len() % 4, 0, "GLB should be 4-byte aligned");
    }
}