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::Color;
use crate::scene::recipe::types::SceneRecipeDiagnosticV1;

use super::super::validate_known_fields;
use super::common::validate_positive_number;
use crate::scene::recipe::validation::diagnostic;

const TEXTURE_SLOT_FIELDS: &[&str] = &["uri", "color_space", "optional"];
const ALPHA_MODE_FIELDS: &[&str] = &["kind", "cutoff"];

pub(in crate::scene::recipe::validation::authoring) fn validate_color_ref(
    path: &str,
    value: Option<&Value>,
    colors: &BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    match value.and_then(Value::as_str) {
        Some(value)
            if colors.contains(value)
                || Color::from_named_constant(value).is_some()
                || Color::from_hex(value).is_ok() => {}
        Some(value) => diagnostics.push(diagnostic(
            "unknown_color_ref",
            "error",
            path,
            format!("base_color references unknown color '{value}'"),
            "reference a key from colors, a Color named constant, or a direct #RRGGBB value",
            None,
            false,
        )),
        None => diagnostics.push(diagnostic(
            "missing_base_color",
            "error",
            path,
            "material base_color must be a color id or #RRGGBB string",
            "reference a key from colors or use a direct #RRGGBB value",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_unit_float(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    match value.as_f64() {
        Some(value) if value.is_finite() && (0.0..=1.0).contains(&value) => {}
        _ => diagnostics.push(diagnostic(
            "invalid_unit_value",
            "error",
            path,
            "material scalar must be finite and in [0, 1]",
            "use a normalized value such as 0.6",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_optional_positive(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    if value.is_some() {
        validate_positive_number(path, value, diagnostics);
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_optional_ior(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    match value.as_f64() {
        Some(value) if value.is_finite() && (value == 0.0 || value >= 1.0) => {}
        _ => diagnostics.push(diagnostic(
            "invalid_ior",
            "error",
            path,
            "ior must be finite and either 0 or greater than or equal to 1.0",
            "use 0 for the MaterialDesc sentinel or a physical IOR such as 1.45",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_optional_non_negative(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    match value.as_f64() {
        Some(value) if value.is_finite() && value >= 0.0 => {}
        _ => diagnostics.push(diagnostic(
            "invalid_number",
            "error",
            path,
            "field must be finite and non-negative",
            "use a value greater than or equal to zero",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_optional_finite(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    match value.as_f64() {
        Some(value) if value.is_finite() => {}
        _ => diagnostics.push(diagnostic(
            "invalid_number",
            "error",
            path,
            "field must be finite",
            "use a finite number",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_optional_range(
    path: &str,
    value: Option<&Value>,
    min: f64,
    max: f64,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    match value.as_f64() {
        Some(value) if value.is_finite() && (min..=max).contains(&value) => {}
        _ => diagnostics.push(diagnostic(
            "invalid_number",
            "error",
            path,
            format!("field must be finite and in [{min}, {max}]"),
            "use a value inside the accepted range",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_alpha_mode(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_alpha_mode",
            "error",
            path,
            "alpha_mode must be an object with kind",
            "use {kind:\"opaque\"}, {kind:\"blend\"}, or {kind:\"mask\",cutoff}",
            None,
            false,
        ));
        return;
    };
    validate_known_fields(path, object, ALPHA_MODE_FIELDS, diagnostics);
    match object.get("kind").and_then(Value::as_str) {
        Some("opaque" | "blend") => {}
        Some("mask") => validate_optional_range(
            &format!("{path}.cutoff"),
            object.get("cutoff"),
            0.0,
            1.0,
            diagnostics,
        ),
        Some(kind) => diagnostics.push(diagnostic(
            "unsupported_feature",
            "error",
            format!("{path}.kind"),
            format!("alpha mode '{kind}' is not supported"),
            "use opaque, mask, or blend",
            None,
            false,
        )),
        None => diagnostics.push(diagnostic(
            "missing_alpha_mode",
            "error",
            format!("{path}.kind"),
            "alpha_mode must include kind",
            "use opaque, mask, or blend",
            None,
            false,
        )),
    }
}

pub(in crate::scene::recipe::validation::authoring) fn validate_texture_slot(
    path: &str,
    value: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_texture_slot",
            "error",
            path,
            "texture slot must be an object",
            "emit {uri,color_space?,optional?}",
            None,
            false,
        ));
        return;
    };
    validate_known_fields(path, object, TEXTURE_SLOT_FIELDS, diagnostics);
    match object.get("uri").and_then(Value::as_str) {
        Some(uri) if !uri.trim().is_empty() => {}
        _ => diagnostics.push(diagnostic(
            "missing_texture_uri",
            "error",
            format!("{path}.uri"),
            "texture slot must include a non-empty uri",
            "point uri at a texture asset",
            None,
            false,
        )),
    }
    if let Some(color_space) = object.get("color_space") {
        match color_space.as_str() {
            Some("srgb" | "linear") => {}
            _ => diagnostics.push(diagnostic(
                "invalid_texture_color_space",
                "error",
                format!("{path}.color_space"),
                "texture color_space must be srgb or linear",
                "use srgb for color/emissive textures and linear for data textures",
                None,
                false,
            )),
        }
    }
    if object
        .get("optional")
        .is_some_and(|value| !value.is_boolean())
    {
        diagnostics.push(diagnostic(
            "invalid_optional",
            "error",
            format!("{path}.optional"),
            "texture optional must be a boolean",
            "set optional true only when the fallback is acceptable",
            None,
            false,
        ));
    }
}