scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use serde_json::{Map, Value};

use crate::scene::recipe::types::SceneRecipeDiagnosticV1;

use super::super::{validate_known_fields, validate_required_id};
use super::common::{
    TransformUse, validate_optional_i16, validate_optional_u64, validate_ref, validate_tags,
    validate_transform,
};
use crate::scene::recipe::validation::diagnostic;

mod lod;
mod skin;

use lod::validate_lods;
use skin::{SkinJointIndexLimit, validate_skin_binding};

const NODE_FIELDS: &[&str] = &[
    "id",
    "geometry",
    "material",
    "parent",
    "name",
    "tags",
    "visible",
    "layer_mask",
    "render_group",
    "tint",
    "transform",
    "lods",
    "morph_weights",
    "skin_binding",
];
pub(in crate::scene::recipe::validation::authoring) fn has_authored_renderable_nodes(
    object: &Map<String, Value>,
) -> bool {
    object
        .get("nodes")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .any(|node| {
            node.as_object().is_some_and(|node| {
                node.get("geometry").is_some_and(Value::is_string)
                    && node.get("material").is_some_and(Value::is_string)
            })
        })
}

pub(in crate::scene::recipe::validation::authoring) struct NodeValidationResources<'a> {
    pub(in crate::scene::recipe::validation::authoring) geometries: &'a BTreeSet<String>,
    pub(in crate::scene::recipe::validation::authoring) materials: &'a BTreeSet<String>,
    pub(in crate::scene::recipe::validation::authoring) morph_target_counts:
        &'a BTreeMap<String, usize>,
    pub(in crate::scene::recipe::validation::authoring) skinned_geometries: &'a BTreeSet<String>,
    pub(in crate::scene::recipe::validation::authoring) skin_max_joint_indices:
        &'a BTreeMap<String, SkinJointIndexLimit>,
    pub(in crate::scene::recipe::validation::authoring) all_node_ids: &'a BTreeSet<String>,
    pub(in crate::scene::recipe::validation::authoring) imports: &'a BTreeSet<String>,
}

pub(in crate::scene::recipe::validation::authoring) fn validate_nodes(
    value: Option<&Value>,
    resources: NodeValidationResources<'_>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    let Some(nodes) = value.as_array() else {
        diagnostics.push(diagnostic(
            "invalid_nodes",
            "error",
            "$.nodes",
            "nodes must be an array",
            "emit nodes:[{id,geometry,material}]",
            None,
            false,
        ));
        return;
    };
    for (index, node) in nodes.iter().enumerate() {
        let path = format!("$.nodes[{index}]");
        let Some(object) = node.as_object() else {
            diagnostics.push(diagnostic(
                "invalid_node",
                "error",
                &path,
                "node entry must be an object",
                "emit node entries as {id, geometry, material}",
                None,
                false,
            ));
            continue;
        };
        validate_known_fields(&path, object, NODE_FIELDS, diagnostics);
        validate_required_id(&path, object.get("id"), diagnostics);
        let geometry_id = object.get("geometry").and_then(Value::as_str);
        validate_ref(
            &format!("{path}.geometry"),
            object.get("geometry"),
            resources.geometries,
            "geometry",
            diagnostics,
        );
        validate_ref(
            &format!("{path}.material"),
            object.get("material"),
            resources.materials,
            "material",
            diagnostics,
        );
        if let Some(parent) = object.get("parent") {
            validate_ref(
                &format!("{path}.parent"),
                Some(parent),
                &node_ids_before(nodes, index),
                "node",
                diagnostics,
            );
        }
        if object
            .get("name")
            .is_some_and(|name| !name.is_string() && !name.is_null())
        {
            diagnostics.push(diagnostic(
                "invalid_name",
                "error",
                format!("{path}.name"),
                "node name must be a string when present",
                "use a human-readable node name or omit name",
                None,
                false,
            ));
        }
        validate_tags(&format!("{path}.tags"), object.get("tags"), diagnostics);
        if object
            .get("visible")
            .is_some_and(|value| !value.is_boolean())
        {
            diagnostics.push(diagnostic(
                "invalid_visible",
                "error",
                format!("{path}.visible"),
                "visible must be a boolean",
                "use true or false",
                None,
                false,
            ));
        }
        validate_optional_u64(
            &format!("{path}.layer_mask"),
            object.get("layer_mask"),
            diagnostics,
        );
        validate_optional_i16(
            &format!("{path}.render_group"),
            object.get("render_group"),
            diagnostics,
        );
        if object
            .get("tint")
            .is_some_and(|tint| !tint.is_string() && !tint.is_null())
        {
            diagnostics.push(diagnostic(
                "invalid_tint",
                "error",
                format!("{path}.tint"),
                "tint must be a color id or #RRGGBB string",
                "reference a declared color or omit tint",
                None,
                false,
            ));
        }
        if let Some(transform) = object.get("transform") {
            validate_transform(
                &format!("{path}.transform"),
                transform,
                TransformUse::Node,
                &node_ids_before(nodes, index),
                resources.imports,
                diagnostics,
            );
        }
        validate_lods(
            &format!("{path}.lods"),
            object.get("lods"),
            resources.geometries,
            diagnostics,
        );
        validate_morph_weights(
            &format!("{path}.morph_weights"),
            object.get("morph_weights"),
            geometry_id.and_then(|geometry| resources.morph_target_counts.get(geometry).copied()),
            diagnostics,
        );
        validate_skin_binding(
            &format!("{path}.skin_binding"),
            object.get("skin_binding"),
            geometry_id,
            resources.skinned_geometries,
            resources.skin_max_joint_indices,
            resources.all_node_ids,
            diagnostics,
        );
    }
}

fn node_ids_before(nodes: &[Value], exclusive_index: usize) -> BTreeSet<String> {
    nodes
        .iter()
        .take(exclusive_index)
        .filter_map(|node| node.get("id").and_then(Value::as_str))
        .map(str::to_owned)
        .collect()
}

fn validate_morph_weights(
    path: &str,
    value: Option<&Value>,
    target_count: Option<usize>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    let Some(weights) = value.as_array() else {
        diagnostics.push(diagnostic(
            "invalid_morph",
            "error",
            path,
            "morph_weights must be an array of finite numbers",
            "emit one weight per authored morph target",
            None,
            false,
        ));
        return;
    };
    let Some(target_count) = target_count else {
        diagnostics.push(diagnostic(
            "invalid_morph",
            "error",
            path,
            "morph_weights require a geometry with authored morph targets",
            "remove morph_weights or use a morph-derived geometry",
            None,
            false,
        ));
        return;
    };
    if weights.len() != target_count {
        diagnostics.push(diagnostic(
            "invalid_morph",
            "error",
            path,
            format!(
                "morph_weights has {} entries but geometry has {target_count} morph targets",
                weights.len()
            ),
            "emit exactly one weight per authored morph target",
            None,
            false,
        ));
    }
    for (index, weight) in weights.iter().enumerate() {
        match weight.as_f64() {
            Some(weight) if weight.is_finite() && weight.abs() <= f64::from(f32::MAX) => {}
            _ => diagnostics.push(diagnostic(
                "invalid_morph",
                "error",
                format!("{path}[{index}]"),
                "morph weight must be finite and f32-compatible",
                "emit finite numeric weights",
                None,
                false,
            )),
        }
    }
}