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::{Map, Value};

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

use super::super::diagnostic;

pub(super) fn validate_global_ids(
    object: &Map<String, Value>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let mut ids = BTreeSet::new();
    collect_import_ids(object.get("imports"), &mut ids, diagnostics);
    collect_map_ids("colors", object.get("colors"), &mut ids, diagnostics);
    for section in [
        "geometries",
        "morphs",
        "skins",
        "materials",
        "nodes",
        "instance_sets",
        "particles",
        "fonts",
        "labels",
        "clipping_planes",
        "animations",
        "cameras",
        "lights",
    ] {
        collect_array_ids(section, object.get(section), &mut ids, diagnostics);
    }
}

fn collect_import_ids(
    value: Option<&Value>,
    ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(imports) = value.and_then(Value::as_array) else {
        return;
    };
    for (index, import) in imports.iter().enumerate() {
        let path = format!("$.imports[{index}].id");
        if let Some(id) = import.get("id").and_then(Value::as_str) {
            collect_id(&path, id, ids, diagnostics);
        }
    }
}

fn collect_map_ids(
    section: &str,
    value: Option<&Value>,
    ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(object) = value.and_then(Value::as_object) else {
        return;
    };
    for id in object.keys() {
        collect_id(&format!("$.{section}.{id}"), id, ids, diagnostics);
    }
}

fn collect_array_ids(
    section: &str,
    value: Option<&Value>,
    ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(array) = value.and_then(Value::as_array) else {
        return;
    };
    for (index, entry) in array.iter().enumerate() {
        if let Some(id) = entry.get("id").and_then(Value::as_str) {
            collect_id(&format!("$.{section}[{index}].id"), id, ids, diagnostics);
        }
    }
}

fn collect_id(
    path: &str,
    id: &str,
    ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    if id.trim().is_empty() {
        diagnostics.push(diagnostic(
            "invalid_id",
            "error",
            path,
            "recipe id must not be empty",
            "use a stable caller-owned id such as `plate` or `main_camera`",
            None,
            false,
        ));
    } else if !ids.insert(id.to_owned()) {
        diagnostics.push(diagnostic(
            "duplicate_id",
            "error",
            path,
            format!("recipe id '{id}' is used more than once"),
            "make every recipe id globally unique across imports, colors, resources, and nodes",
            None,
            false,
        ));
    }
}

pub(super) fn id_set_from_map(value: Option<&Value>) -> BTreeSet<String> {
    value
        .and_then(Value::as_object)
        .into_iter()
        .flat_map(|object| object.keys())
        .cloned()
        .collect()
}

pub(super) fn id_set_from_array(value: Option<&Value>) -> BTreeSet<String> {
    value
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|entry| entry.get("id").and_then(Value::as_str))
        .map(str::to_owned)
        .collect()
}