rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
#[cfg(any(feature = "gltf", feature = "obj"))]
use super::ModelMesh;
use thiserror::Error;

/// Errors from 3D model loading.
#[derive(Debug, Clone, Error)]
pub enum ModelLoadError {
    /// The file format could not be parsed.
    #[error("parse error: {0}")]
    Parse(String),
    /// The file contains no usable mesh geometry.
    #[error("no mesh found: {0}")]
    NoMesh(String),
}

// ---------------------------------------------------------------------------
// GLTF loader
// ---------------------------------------------------------------------------

#[cfg(feature = "gltf")]
impl ModelMesh {
    /// Load the first mesh from a glTF binary (`.glb`) or JSON (`.gltf`)
    /// buffer.
    ///
    /// Extracts positions, normals, texture coordinates, and triangle
    /// indices from the first primitive of the first mesh in the file.
    /// Missing normals default to `[0, 0, 1]`; missing UVs default to
    /// `[0, 0]`.
    ///
    /// # Errors
    ///
    /// Returns [`ModelLoadError::Parse`] if the glTF data is malformed,
    /// or [`ModelLoadError::NoMesh`] if the file contains no mesh
    /// primitives.
    pub fn from_gltf(bytes: &[u8]) -> Result<Self, ModelLoadError> {
        let (document, buffers, _images) =
            gltf::import_slice(bytes).map_err(|e| ModelLoadError::Parse(e.to_string()))?;

        let mesh = document
            .meshes()
            .next()
            .ok_or_else(|| ModelLoadError::NoMesh("glTF contains no meshes".into()))?;

        let primitive = mesh
            .primitives()
            .next()
            .ok_or_else(|| ModelLoadError::NoMesh("mesh contains no primitives".into()))?;

        let reader = primitive.reader(|buffer| Some(&buffers[buffer.index()]));

        let positions: Vec<[f32; 3]> = reader
            .read_positions()
            .ok_or_else(|| ModelLoadError::NoMesh("primitive has no POSITION attribute".into()))?
            .collect();

        let normals: Vec<[f32; 3]> = reader
            .read_normals()
            .map(|iter| iter.collect())
            .unwrap_or_else(|| vec![[0.0, 0.0, 1.0]; positions.len()]);

        let uvs: Vec<[f32; 2]> = reader
            .read_tex_coords(0)
            .map(|tc| tc.into_f32().collect())
            .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);

        let indices: Vec<u32> = reader
            .read_indices()
            .map(|idx| idx.into_u32().collect())
            .unwrap_or_else(|| (0..positions.len() as u32).collect());

        Ok(ModelMesh {
            positions,
            normals,
            uvs,
            indices,
        })
    }
}

// ---------------------------------------------------------------------------
// OBJ loader
// ---------------------------------------------------------------------------

#[cfg(feature = "obj")]
impl ModelMesh {
    /// Load the first mesh from a Wavefront OBJ buffer.
    ///
    /// Parses the OBJ data (no MTL material support). Extracts
    /// positions, normals, and texture coordinates from the first
    /// model. Missing normals default to `[0, 0, 1]`; missing UVs
    /// default to `[0, 0]`.
    ///
    /// # Errors
    ///
    /// Returns [`ModelLoadError::Parse`] if the OBJ data is malformed,
    /// or [`ModelLoadError::NoMesh`] if the file contains no models.
    pub fn from_obj(bytes: &[u8]) -> Result<Self, ModelLoadError> {
        let mut cursor = std::io::Cursor::new(bytes);
        let (models, _materials) = tobj::load_obj_buf(
            &mut cursor,
            &tobj::LoadOptions {
                triangulate: true,
                single_index: true,
                ..Default::default()
            },
            |_path| Err(tobj::LoadError::OpenFileFailed),
        )
        .map_err(|e| ModelLoadError::Parse(e.to_string()))?;

        let model = models
            .first()
            .ok_or_else(|| ModelLoadError::NoMesh("OBJ contains no models".into()))?;

        let mesh = &model.mesh;
        let vertex_count = mesh.positions.len() / 3;

        let positions: Vec<[f32; 3]> = mesh
            .positions
            .chunks_exact(3)
            .map(|c| [c[0], c[1], c[2]])
            .collect();

        let normals: Vec<[f32; 3]> = if mesh.normals.len() == mesh.positions.len() {
            mesh.normals
                .chunks_exact(3)
                .map(|c| [c[0], c[1], c[2]])
                .collect()
        } else {
            vec![[0.0, 0.0, 1.0]; vertex_count]
        };

        let uvs: Vec<[f32; 2]> = if mesh.texcoords.len() >= vertex_count * 2 {
            mesh.texcoords
                .chunks_exact(2)
                .map(|c| [c[0], c[1]])
                .collect()
        } else {
            vec![[0.0, 0.0]; vertex_count]
        };

        let indices: Vec<u32> = mesh.indices.clone();

        Ok(ModelMesh {
            positions,
            normals,
            uvs,
            indices,
        })
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn model_load_error_display() {
        let e = ModelLoadError::Parse("bad data".into());
        assert!(e.to_string().contains("bad data"));

        let e = ModelLoadError::NoMesh("empty".into());
        assert!(e.to_string().contains("empty"));
    }

    #[cfg(feature = "gltf")]
    #[test]
    fn from_gltf_invalid_bytes() {
        let result = ModelMesh::from_gltf(b"not a gltf file");
        assert!(result.is_err());
    }

    #[cfg(feature = "obj")]
    #[test]
    fn from_obj_invalid_is_error() {
        // Binary garbage should fail to parse.
        let result = ModelMesh::from_obj(&[0xFF, 0xFE, 0x00, 0x01]);
        // tobj may parse garbage as empty or error -- either is acceptable
        // as long as we don't panic.
        let _ = result;
    }

    #[cfg(feature = "obj")]
    #[test]
    fn from_obj_triangle() {
        let obj = b"\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
f 1 2 3
";
        let mesh = ModelMesh::from_obj(obj).expect("parse triangle");
        assert_eq!(mesh.positions.len(), 3);
        assert_eq!(mesh.indices.len(), 3);
        assert_eq!(mesh.normals.len(), 3);
        assert_eq!(mesh.uvs.len(), 3);
    }
}