scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use serde_json::Value;

use super::{
    SceneRecipeDiagnosticV1, diagnostic, validate_finite_number_optional, validate_known_fields,
    validate_non_negative_number_optional, validate_optional_string,
    validate_positive_number_optional, validate_unit_number_optional,
};

const SCENE_FIELDS: &[&str] = &["preset", "background", "environment", "grid"];
const BACKGROUND_FIELDS: &[&str] = &["kind", "color"];
const ENVIRONMENT_FIELDS: &[&str] = &["kind", "preset", "uri", "optional"];
const GRID_FIELDS: &[&str] = &[
    "enabled",
    "under_bounds",
    "floor_y",
    "padding",
    "line_spacing",
    "line_width_px",
    "color",
    "line_color",
    "roughness",
    "reflection",
];
const GRID_REFLECTION_FIELDS: &[&str] = &["enabled", "strength"];

pub(in crate::scene::recipe::validation) fn validate_scene_setup(
    scene: Option<&Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(scene) = scene else {
        return;
    };
    let Some(object) = scene.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_scene_setup",
            "error",
            "$.scene",
            "scene must be an object",
            "emit scene:{background?,environment?,grid?}",
            None,
            false,
        ));
        return;
    };
    validate_known_fields("$.scene", object, SCENE_FIELDS, diagnostics);
    validate_scene_preset(object.get("preset"), diagnostics);
    validate_background(object.get("background"), diagnostics);
    validate_environment(object.get("environment"), diagnostics);
    validate_grid(object.get("grid"), diagnostics);
}

fn validate_scene_preset(value: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    super::validate_enum(
        "$.scene.preset",
        value,
        &["product_studio", "cad_studio", "industrial_studio"],
        "invalid_scene_preset",
        diagnostics,
    );
}

fn validate_background(value: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_background",
            "error",
            "$.scene.background",
            "background must be an object with a kind",
            "use kind:\"white\", kind:\"studio\", or kind:\"custom\" with color",
            None,
            false,
        ));
        return;
    };
    validate_known_fields("$.scene.background", object, BACKGROUND_FIELDS, diagnostics);
    super::validate_enum(
        "$.scene.background.kind",
        object.get("kind"),
        &[
            "studio",
            "dark_studio",
            "neutral_gray",
            "white",
            "black",
            "sky",
            "transparent",
            "custom",
        ],
        "invalid_background",
        diagnostics,
    );
    if object.get("kind").and_then(Value::as_str) == Some("custom") {
        match object.get("color").and_then(Value::as_str) {
            Some(color) if !color.trim().is_empty() => {}
            _ => diagnostics.push(diagnostic(
                "invalid_background",
                "error",
                "$.scene.background.color",
                "custom background requires a non-empty color string",
                "reference a recipe color id or use a direct #RRGGBB value",
                None,
                false,
            )),
        }
    }
}

fn validate_environment(value: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment",
            "environment must be an object with a kind",
            "use kind:\"default\", kind:\"uri\", or kind:\"none\"",
            None,
            false,
        ));
        return;
    };
    validate_known_fields(
        "$.scene.environment",
        object,
        ENVIRONMENT_FIELDS,
        diagnostics,
    );
    let kind = object.get("kind").and_then(Value::as_str);
    let preset = object.get("preset").and_then(Value::as_str);
    if object.contains_key("kind") && object.contains_key("preset") {
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment.preset",
            "environment must use either kind or preset, not both",
            "remove kind when using environment.preset, or remove preset for default/uri/none",
            None,
            false,
        ));
    }
    if let Some(kind) = kind {
        if !matches!(kind, "default" | "uri" | "none") {
            diagnostics.push(diagnostic(
                "invalid_environment",
                "error",
                "$.scene.environment.kind",
                format!("unsupported environment kind '{kind}'"),
                "use default, uri, or none",
                None,
                false,
            ));
        }
    } else if preset.is_none() {
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment.kind",
            "environment requires kind or preset",
            "use kind:\"default\", kind:\"uri\", kind:\"none\", or preset:\"studio\"",
            None,
            false,
        ));
    }
    if let Some(preset) = preset
        && crate::EnvironmentPreset::from_recipe_name(preset).is_none()
    {
        let names = crate::EnvironmentPreset::ALL
            .iter()
            .map(|preset| preset.recipe_name())
            .collect::<Vec<_>>()
            .join(", ");
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment.preset",
            format!("environment preset '{preset}' is not supported"),
            format!("use one of: {names}"),
            None,
            false,
        ));
    }
    if kind == Some("uri") {
        match object.get("uri").and_then(Value::as_str) {
            Some(uri) if !uri.trim().is_empty() => {}
            _ => diagnostics.push(diagnostic(
                "invalid_environment",
                "error",
                "$.scene.environment.uri",
                "uri environment requires a non-empty uri string",
                "provide an environment asset path allowed by RecipeBuildPolicy",
                None,
                false,
            )),
        }
    } else if object.contains_key("uri") {
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment.uri",
            "uri only applies to kind:\"uri\" environments",
            "remove uri or use kind:\"uri\"",
            None,
            false,
        ));
    }
    if let Some(optional) = object.get("optional")
        && optional.as_bool().is_none()
    {
        diagnostics.push(diagnostic(
            "invalid_environment",
            "error",
            "$.scene.environment.optional",
            "optional must be a boolean",
            "use true only when an environment fallback is acceptable",
            None,
            false,
        ));
    }
}

fn validate_grid(value: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_grid",
            "error",
            "$.scene.grid",
            "grid must be an object",
            "emit grid:{enabled?,padding?,line_spacing?,line_width_px?,floor_y?,color?,line_color?,roughness?}",
            None,
            false,
        ));
        return;
    };
    validate_known_fields("$.scene.grid", object, GRID_FIELDS, diagnostics);
    if let Some(enabled) = object.get("enabled")
        && enabled.as_bool().is_none()
    {
        diagnostics.push(diagnostic(
            "invalid_grid",
            "error",
            "$.scene.grid.enabled",
            "enabled must be a boolean",
            "use true or false",
            None,
            false,
        ));
    }
    if let Some(under_bounds) = object.get("under_bounds")
        && under_bounds.as_bool().is_none()
    {
        diagnostics.push(diagnostic(
            "invalid_grid",
            "error",
            "$.scene.grid.under_bounds",
            "under_bounds must be a boolean",
            "use true to call GridFloorOptions::under_bounds for content-sized floors",
            None,
            false,
        ));
    }
    validate_finite_number_optional("$.scene.grid.floor_y", object.get("floor_y"), diagnostics);
    validate_non_negative_number_optional(
        "$.scene.grid.padding",
        object.get("padding"),
        diagnostics,
    );
    validate_positive_number_optional(
        "$.scene.grid.line_spacing",
        object.get("line_spacing"),
        diagnostics,
    );
    validate_positive_number_optional(
        "$.scene.grid.line_width_px",
        object.get("line_width_px"),
        diagnostics,
    );
    validate_unit_number_optional(
        "$.scene.grid.roughness",
        object.get("roughness"),
        diagnostics,
    );
    validate_optional_string("$.scene.grid.color", object.get("color"), diagnostics);
    validate_optional_string(
        "$.scene.grid.line_color",
        object.get("line_color"),
        diagnostics,
    );
    validate_grid_reflection(object.get("reflection"), diagnostics);
}

fn validate_grid_reflection(value: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(value) = value else {
        return;
    };
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_grid",
            "error",
            "$.scene.grid.reflection",
            "grid reflection must be an object",
            "emit reflection:{enabled?,strength?}",
            None,
            false,
        ));
        return;
    };
    validate_known_fields(
        "$.scene.grid.reflection",
        object,
        GRID_REFLECTION_FIELDS,
        diagnostics,
    );
    if let Some(enabled) = object.get("enabled")
        && enabled.as_bool().is_none()
    {
        diagnostics.push(diagnostic(
            "invalid_grid",
            "error",
            "$.scene.grid.reflection.enabled",
            "enabled must be a boolean",
            "use true or false",
            None,
            false,
        ));
    }
    validate_unit_number_optional(
        "$.scene.grid.reflection.strength",
        object.get("strength"),
        diagnostics,
    );
}