concinnity-core 0.19.9

Runtime vocabulary for the Concinnity engine: GPU layouts, ECS components, registry, CPU kernels
Documentation
// src/components/file.rs
//
// Runtime `File` component. Its authored args and `FileKind` live in the schema
// crate (concinnity_core::components::file).

use crate::ecs::asset_id::AssetId;
use crate::ecs::{Component, PayloadLocator};
use alloc::string::String;

/// The category of file content, inferred from the extension when not supplied.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileKind {
    /// Wavefront OBJ geometry.
    Obj,
    /// PNG image.
    Png,
    /// JPEG image, `.jpg`.
    Jpg,
    /// JPEG image, `.jpeg`.
    Jpeg,
    /// Windows bitmap image.
    Bmp,
    /// Truevision TGA image.
    Tga,
    /// GIF image.
    Gif,
    /// TrueType font.
    Ttf,
    /// OpenType font.
    Otf,
    /// Plain text.
    Txt,
    /// Markdown text, the medium the story importer reads.
    Md,
    /// Wavefront material library accompanying an OBJ.
    Mtl,
}

impl FileKind {
    /// The kind an extension names, case-insensitively. `None` for an
    /// extension the engine does not read.
    pub fn from_ext(ext: &str) -> Option<Self> {
        match ext.to_lowercase().as_str() {
            "obj" => Some(Self::Obj),
            "png" => Some(Self::Png),
            "jpg" => Some(Self::Jpg),
            "jpeg" => Some(Self::Jpeg),
            "bmp" => Some(Self::Bmp),
            "tga" => Some(Self::Tga),
            "gif" => Some(Self::Gif),
            "ttf" => Some(Self::Ttf),
            "otf" => Some(Self::Otf),
            "txt" => Some(Self::Txt),
            "md" => Some(Self::Md),
            "mtl" => Some(Self::Mtl),
            _ => None,
        }
    }

    /// Returns true for kinds whose build output is a mesh blob compatible with the
    /// mesh payload format (vertex + index data readable by GraphicsSystem).
    pub fn is_mesh(&self) -> bool {
        matches!(self, Self::Obj)
    }
}

/// Authored fields of a `File`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct FileArgs {
    /// Path to the source file, relative to the project root.
    pub path: String,
    /// File category. Inferred from the path extension when absent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<FileKind>,
}

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

    #[test]
    fn every_kind_is_reachable_from_an_extension() {
        // The inference table is the only way a kind is assigned when the args
        // omit one, so a kind missing from it can never be produced.
        let all = [
            ("obj", FileKind::Obj),
            ("png", FileKind::Png),
            ("jpg", FileKind::Jpg),
            ("jpeg", FileKind::Jpeg),
            ("bmp", FileKind::Bmp),
            ("tga", FileKind::Tga),
            ("gif", FileKind::Gif),
            ("ttf", FileKind::Ttf),
            ("otf", FileKind::Otf),
            ("txt", FileKind::Txt),
            ("md", FileKind::Md),
            ("mtl", FileKind::Mtl),
        ];
        for (ext, kind) in all {
            assert_eq!(FileKind::from_ext(ext).as_ref(), Some(&kind), "{ext}");
            // Extensions are matched case-insensitively.
            assert_eq!(
                FileKind::from_ext(&ext.to_uppercase()).as_ref(),
                Some(&kind),
                "{ext}"
            );
        }
    }

    #[test]
    fn an_unknown_extension_has_no_kind() {
        assert_eq!(FileKind::from_ext("wav"), None);
        assert_eq!(FileKind::from_ext(""), None);
    }

    #[test]
    fn only_obj_builds_to_a_mesh_payload() {
        assert!(FileKind::Obj.is_mesh());
        for kind in [FileKind::Png, FileKind::Ttf, FileKind::Mtl, FileKind::Md] {
            assert!(!kind.is_mesh(), "{kind:?}");
        }
    }

    #[test]
    fn args_default_to_an_empty_path_and_inferred_kind() {
        let args = FileArgs::default();
        assert!(args.path.is_empty());
        assert_eq!(args.kind, None);
    }

    #[test]
    fn an_absent_kind_is_omitted_from_the_serialized_args() {
        let args: FileArgs = serde_json::from_str(r#"{"path":"assets/board.obj"}"#).unwrap();
        assert_eq!(args.path, "assets/board.obj");
        assert_eq!(args.kind, None);
        // `cn add` writes normalized args back, so an inferred kind stays absent
        // rather than being frozen into the world file.
        assert_eq!(
            serde_json::to_string(&args).unwrap(),
            r#"{"path":"assets/board.obj"}"#
        );
    }

    #[test]
    fn an_explicit_kind_round_trips_through_its_lowercase_name() {
        let args: FileArgs = serde_json::from_str(r#"{"path":"font.dat","kind":"ttf"}"#).unwrap();
        assert_eq!(args.kind, Some(FileKind::Ttf));
        assert_eq!(
            serde_json::to_string(&args).unwrap(),
            r#"{"path":"font.dat","kind":"ttf"}"#
        );
        let bytes = postcard::to_allocvec(&args).unwrap();
        let back: FileArgs = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back.kind, Some(FileKind::Ttf));
        assert_eq!(back.path, "font.dat");
    }
}

/// References a source file by path.
///
/// For supported kinds the build compiles the file into the world (an `.obj`
/// becomes mesh data); other kinds are path-only references.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct File {
    /// Assigned by the loader; not authored.
    pub asset_id: AssetId,
    /// Path to the source file, relative to the world.
    pub path: String,
    /// Content category, derived from the extension when not authored.
    pub kind: Option<FileKind>,
    /// Injected at load time for kinds that produce a compiled blob (e.g. obj → mesh payload).
    pub locator: Option<PayloadLocator>,
}

impl File {
    /// Translate the authored args into the runtime file reference: derive
    /// `kind` from the path extension when unset. Run by cook at build time
    /// (the baked blob record carries the result).
    pub fn bake(args: FileArgs) -> Self {
        let kind = args
            .kind
            .clone()
            .or_else(|| super::path_extension(&args.path).and_then(FileKind::from_ext));
        Self {
            asset_id: AssetId::default(),
            path: args.path,
            kind,
            locator: None,
        }
    }
}

impl Component for File {
    const NAME: &'static str = "File";

    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
        Ok(crate::blob::decode_exact(bytes)?)
    }

    fn inject_locator(&mut self, locator: PayloadLocator) {
        self.locator = Some(locator);
    }

    fn inject_name(&mut self, id: AssetId) {
        self.asset_id = id;
    }
}

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

    #[test]
    fn from_ext_maps_every_known_extension() {
        let cases = [
            ("obj", FileKind::Obj),
            ("png", FileKind::Png),
            ("jpg", FileKind::Jpg),
            ("jpeg", FileKind::Jpeg),
            ("bmp", FileKind::Bmp),
            ("tga", FileKind::Tga),
            ("gif", FileKind::Gif),
            ("ttf", FileKind::Ttf),
            ("otf", FileKind::Otf),
            ("txt", FileKind::Txt),
            ("md", FileKind::Md),
            ("mtl", FileKind::Mtl),
        ];
        for (ext, want) in cases {
            assert_eq!(FileKind::from_ext(ext), Some(want.clone()));
            // Matching is case-insensitive.
            assert_eq!(FileKind::from_ext(&ext.to_uppercase()), Some(want));
        }
        assert_eq!(FileKind::from_ext("zzz"), None);
    }

    #[test]
    fn from_args_infers_kind_from_the_extension() {
        // No explicit kind -> inferred from the path.
        let f = File::bake(FileArgs {
            path: "models/box.obj".into(),
            kind: None,
        });
        assert_eq!(f.kind, Some(FileKind::Obj));
        assert_eq!(f.path, "models/box.obj");
        // An explicit kind is kept even when it disagrees with the extension.
        let g = File::bake(FileArgs {
            path: "data.obj".into(),
            kind: Some(FileKind::Txt),
        });
        assert_eq!(g.kind, Some(FileKind::Txt));
        // An unknown extension leaves the kind unset.
        let h = File::bake(FileArgs {
            path: "notes.zzz".into(),
            kind: None,
        });
        assert_eq!(h.kind, None);
    }

    #[test]
    fn is_mesh_is_true_only_for_obj() {
        assert!(FileKind::Obj.is_mesh());
        assert!(!FileKind::Png.is_mesh());
        assert!(!FileKind::Ttf.is_mesh());
    }

    #[test]
    fn file_args_and_kind_round_trip_through_json() {
        let args = FileArgs {
            path: "x.png".into(),
            kind: Some(FileKind::Png),
        };
        let value = serde_json::to_value(&args).unwrap();
        let back: FileArgs = serde_json::from_value(value).unwrap();
        assert_eq!(back.path, "x.png");
        assert_eq!(back.kind, Some(FileKind::Png));
        // FileKind serializes to its lowercase name.
        assert_eq!(serde_json::to_string(&FileKind::Jpeg).unwrap(), "\"jpeg\"");
        // to_args mirrors the component fields.
        assert_eq!(File::bake(args).kind, Some(FileKind::Png));
    }
}