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::BTreeSet;

use serde_json::Value;

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

use super::super::validate_known_fields;
use super::common::{
    TransformUse, validate_optional_i16, validate_optional_u64, validate_transform, validate_vec3,
};
use super::extras::validate_color_value;
use super::import_refs::validate_node_ref;

const PARTICLE_SET_FIELDS: &[&str] = &[
    "id",
    "parent",
    "transform",
    "visible",
    "layer_mask",
    "render_group",
    "particles",
];
const PARTICLE_FIELDS: &[&str] = &["id", "position", "color", "size_px", "rotation_degrees"];

pub(in crate::scene::recipe::validation::authoring) fn has_authored_particle_sets(
    value: Option<&Value>,
) -> bool {
    value
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .any(|entry| {
            entry
                .get("particles")
                .and_then(Value::as_array)
                .is_some_and(|particles| !particles.is_empty())
        })
}

pub(in crate::scene::recipe::validation::authoring) fn validate_particle_sets(
    value: Option<&Value>,
    colors: &BTreeSet<String>,
    node_ids: &BTreeSet<String>,
    import_ids: &BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    let Some(particle_sets) = value.as_array() else {
        diagnostics.push(diagnostic(
            "invalid_particles",
            "error",
            "$.particles",
            "particles must be an array of particle set objects",
            "emit particles:[{id,particles:[...]}]",
            None,
            false,
        ));
        return;
    };
    for (index, particle_set) in particle_sets.iter().enumerate() {
        let path = format!("$.particles[{index}]");
        let Some(object) = particle_set.as_object() else {
            diagnostics.push(diagnostic(
                "invalid_particle_set",
                "error",
                &path,
                "particle set entry must be an object",
                "emit {id, particles:[{id,position,color,size_px}]}",
                None,
                false,
            ));
            continue;
        };
        validate_known_fields(&path, object, PARTICLE_SET_FIELDS, diagnostics);
        validate_particle_set_id(&format!("{path}.id"), object.get("id"), diagnostics);
        if let Some(parent) = object.get("parent") {
            validate_node_ref(
                &format!("{path}.parent"),
                Some(parent),
                node_ids,
                import_ids,
                diagnostics,
            );
        }
        if let Some(transform) = object.get("transform") {
            validate_transform(
                &format!("{path}.transform"),
                transform,
                TransformUse::Node,
                node_ids,
                import_ids,
                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,
        );
        validate_particles(&path, object.get("particles"), colors, diagnostics);
    }
}

fn validate_particles(
    path: &str,
    value: Option<&Value>,
    colors: &BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        diagnostics.push(diagnostic(
            "missing_particles",
            "error",
            format!("{path}.particles"),
            "particle set must include at least one particle",
            "emit particles:[{id,position,color,size_px}]",
            None,
            false,
        ));
        return;
    };
    let Some(particles) = value.as_array().filter(|particles| !particles.is_empty()) else {
        diagnostics.push(diagnostic(
            "invalid_particles",
            "error",
            format!("{path}.particles"),
            "particles must be a non-empty array",
            "emit one object per host-supplied particle",
            None,
            false,
        ));
        return;
    };
    let mut ids = BTreeSet::new();
    for (index, particle) in particles.iter().enumerate() {
        let particle_path = format!("{path}.particles[{index}]");
        let Some(object) = particle.as_object() else {
            diagnostics.push(diagnostic(
                "invalid_particle",
                "error",
                &particle_path,
                "particle entry must be an object",
                "emit {id, position:[x,y,z], color, size_px}",
                None,
                false,
            ));
            continue;
        };
        validate_known_fields(&particle_path, object, PARTICLE_FIELDS, diagnostics);
        validate_particle_id(
            &format!("{particle_path}.id"),
            object.get("id"),
            &mut ids,
            diagnostics,
        );
        validate_vec3(
            &format!("{particle_path}.position"),
            object.get("position"),
            diagnostics,
        );
        validate_color_value(
            &format!("{particle_path}.color"),
            object.get("color"),
            colors,
            diagnostics,
        );
        match object.get("size_px").and_then(Value::as_f64) {
            Some(size) if size.is_finite() && size > 0.0 && size <= f64::from(f32::MAX) => {}
            _ => diagnostics.push(diagnostic(
                "invalid_particle",
                "error",
                format!("{particle_path}.size_px"),
                "particle size_px must be finite and positive",
                "use a positive screen-space sprite size in pixels",
                None,
                false,
            )),
        }
        if let Some(rotation) = object.get("rotation_degrees") {
            match rotation.as_f64() {
                Some(rotation) if rotation.is_finite() => {}
                _ => diagnostics.push(diagnostic(
                    "invalid_particle",
                    "error",
                    format!("{particle_path}.rotation_degrees"),
                    "particle rotation_degrees must be finite when present",
                    "omit rotation_degrees or use a finite degree value",
                    None,
                    false,
                )),
            }
        }
    }
}

fn validate_particle_set_id(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    match value.and_then(Value::as_str) {
        Some(id) if !id.trim().is_empty() => {}
        Some(_) => diagnostics.push(diagnostic(
            "invalid_id",
            "error",
            path,
            "particle set id must not be empty",
            "use a stable caller-owned id",
            None,
            false,
        )),
        None => diagnostics.push(diagnostic(
            "missing_id",
            "error",
            path,
            "particle set must include an id string",
            "add a stable caller-owned id",
            None,
            false,
        )),
    }
}

fn validate_particle_id(
    path: &str,
    value: Option<&Value>,
    ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    match value.and_then(Value::as_str) {
        Some(id) if !id.trim().is_empty() && ids.insert(id.to_owned()) => {}
        Some(id) if id.trim().is_empty() => diagnostics.push(diagnostic(
            "invalid_id",
            "error",
            path,
            "particle id must not be empty",
            "use a stable per-particle id",
            None,
            false,
        )),
        Some(id) => diagnostics.push(diagnostic(
            "duplicate_id",
            "error",
            path,
            format!("particle id '{id}' is used more than once in this set"),
            "make per-particle ids unique within the set",
            None,
            false,
        )),
        None => diagnostics.push(diagnostic(
            "missing_id",
            "error",
            path,
            "particle entry must include an id string",
            "add a stable per-particle id",
            None,
            false,
        )),
    }
}