scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use crate::assets::{AssetFetcher, Assets, SceneAsset};
use crate::geometry::Aabb;
use crate::scene::Vec3;

use super::checks::finding;
use super::{AssetCatalogAssetV1, AssetReadinessFindingV1, AssetReadinessSeverityV1};

pub(super) fn validate_loaded_asset(
    assets: &Assets<impl AssetFetcher>,
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    validate_bounds(catalog_asset, asset, findings);
    validate_authored_features(catalog_asset, asset, findings);
    validate_material_requirements(assets, catalog_asset, asset, findings);
}

fn validate_bounds(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    let Some(bounds) = asset.bounds() else {
        if catalog_asset.expected_bounds.is_some() {
            findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "bounds_missing",
                "asset has no renderable bounds to compare against expected limits",
                "add renderable geometry or remove the expected bounds requirement",
                Some(catalog_asset.source.clone()),
                Some("expected_bounds"),
            ));
        }
        return;
    };

    if !aabb_is_finite(bounds) {
        findings.push(finding(
            AssetReadinessSeverityV1::Error,
            "bounds_not_finite",
            "asset bounds contain non-finite coordinates",
            "fix the source geometry before catalog approval",
            Some(catalog_asset.source.clone()),
            Some("expected_bounds"),
        ));
        return;
    }

    let Some(expected) = catalog_asset.expected_bounds.as_ref() else {
        return;
    };

    if expected
        .min_extent
        .is_some_and(|value| !valid_extent(value))
        || expected
            .max_extent
            .is_some_and(|value| !valid_extent(value))
    {
        findings.push(finding(
            AssetReadinessSeverityV1::Error,
            "expected_bounds_invalid",
            "expected extent limits must be finite positive numbers",
            "remove invalid extent limits or set positive finite values",
            None,
            Some("expected_bounds"),
        ));
        return;
    }

    if let (Some(min), Some(max)) = (expected.min, expected.max)
        && (bounds.min.x < min.x
            || bounds.min.y < min.y
            || bounds.min.z < min.z
            || bounds.max.x > max.x
            || bounds.max.y > max.y
            || bounds.max.z > max.z)
    {
        findings.push(finding(
            AssetReadinessSeverityV1::Error,
            "bounds_out_of_range",
            "asset bounds fall outside the declared expected bounds",
            "fix the asset scale/origin or widen the expected bounds intentionally",
            Some(catalog_asset.source.clone()),
            Some("expected_bounds"),
        ));
    }

    let extent = max_extent(bounds);
    if expected.min_extent.is_some_and(|min| extent < min)
        || expected.max_extent.is_some_and(|max| extent > max)
    {
        findings.push(finding(
            AssetReadinessSeverityV1::Error,
            "extent_out_of_range",
            "asset maximum extent falls outside the declared scale limits",
            "fix the asset units/scale or update expected extent limits intentionally",
            Some(catalog_asset.source.clone()),
            Some("expected_bounds"),
        ));
    }
}

fn validate_authored_features(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    validate_authored_feature_integrity(catalog_asset, asset, findings);
    validate_required_anchors(catalog_asset, asset, findings);
    validate_required_connectors(catalog_asset, asset, findings);
    validate_required_tags(catalog_asset, asset, findings);
}

fn validate_authored_feature_integrity(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    for node in asset.nodes() {
        for anchor in node.anchors() {
            if let Some(reason) = anchor.invalid_reason() {
                findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "invalid_anchor",
                    format!("anchor '{}' is invalid: {reason}", anchor.name()),
                    "fix the glTF extras.scena.anchors entry",
                    Some(catalog_asset.source.clone()),
                    Some("required_anchors"),
                ));
            }
        }
        for connector in node.connectors() {
            if let Some(reason) = connector.invalid_reason() {
                findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "invalid_connector",
                    format!("connector '{}' is invalid: {reason}", connector.name()),
                    "fix the glTF extras.scena.connectors entry",
                    Some(catalog_asset.source.clone()),
                    Some("required_connectors"),
                ));
            }
        }
    }
}

fn validate_required_anchors(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    for requirement in &catalog_asset.required_anchors {
        let matching = asset
            .nodes()
            .iter()
            .flat_map(|node| node.anchors())
            .find(|anchor| anchor.name() == requirement.name);
        match matching {
            Some(anchor) => {
                if let Some(missing_tag) = requirement
                    .tags
                    .iter()
                    .find(|tag| !anchor.tags().contains(tag.as_str()))
                {
                    findings.push(finding(
                        AssetReadinessSeverityV1::Error,
                        "required_anchor_tag_missing",
                        format!(
                            "anchor '{}' is missing required tag '{}'",
                            requirement.name, missing_tag
                        ),
                        "add the required tag to the anchor extras or update the manifest",
                        Some(catalog_asset.source.clone()),
                        Some("required_anchors"),
                    ));
                }
            }
            None => findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_anchor_missing",
                format!("required anchor '{}' is missing", requirement.name),
                "author the anchor in glTF extras.scena.anchors or remove the requirement",
                Some(catalog_asset.source.clone()),
                Some("required_anchors"),
            )),
        }
    }
}

fn validate_required_connectors(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    for requirement in &catalog_asset.required_connectors {
        let matching = asset
            .nodes()
            .iter()
            .flat_map(|node| node.connectors())
            .find(|connector| connector.name() == requirement.name);
        match matching {
            Some(connector) => {
                if let Some(missing_tag) = requirement
                    .tags
                    .iter()
                    .find(|tag| !connector.tags().contains(tag.as_str()))
                {
                    findings.push(finding(
                        AssetReadinessSeverityV1::Error,
                        "required_connector_tag_missing",
                        format!(
                            "connector '{}' is missing required tag '{}'",
                            requirement.name, missing_tag
                        ),
                        "add the required tag to the connector extras or update the manifest",
                        Some(catalog_asset.source.clone()),
                        Some("required_connectors"),
                    ));
                }
            }
            None => findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_connector_missing",
                format!("required connector '{}' is missing", requirement.name),
                "author the connector in glTF extras.scena.connectors or remove the requirement",
                Some(catalog_asset.source.clone()),
                Some("required_connectors"),
            )),
        }
    }
}

fn validate_required_tags(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    for tag in &catalog_asset.required_tags {
        let found = asset.nodes().iter().any(|node| {
            node.anchors()
                .iter()
                .any(|anchor| anchor.tags().contains(tag.as_str()))
                || node
                    .connectors()
                    .iter()
                    .any(|connector| connector.tags().contains(tag.as_str()))
        });
        if !found {
            findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_tag_missing",
                format!("required authored feature tag '{tag}' is missing"),
                "add the tag to an authored anchor/connector or update the manifest",
                Some(catalog_asset.source.clone()),
                Some("required_tags"),
            ));
        }
    }
}

fn validate_material_requirements(
    assets: &Assets<impl AssetFetcher>,
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    validate_required_material_variants(catalog_asset, asset, findings);
    validate_material_fallback_policy(catalog_asset, asset, findings);
    validate_base_color_texture_requirement(assets, catalog_asset, asset, findings);
}

fn validate_required_material_variants(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    for variant in &catalog_asset.material_requirements.required_variants {
        if !asset
            .material_variants()
            .iter()
            .any(|candidate| candidate == variant)
        {
            findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_material_variant_missing",
                format!("required material variant '{variant}' is missing"),
                "author the KHR_materials_variants entry or remove the requirement",
                Some(catalog_asset.source.clone()),
                Some("material_requirements.required_variants"),
            ));
        }
    }
}

fn validate_material_fallback_policy(
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    if !catalog_asset.material_requirements.allow_material_fallbacks
        && !asset.material_fallbacks().is_empty()
    {
        findings.push(finding(
            AssetReadinessSeverityV1::Error,
            "material_fallback_used",
            "asset used one or more material fallback sources",
            "provide the preferred source material/texture or explicitly allow fallbacks",
            Some(catalog_asset.source.clone()),
            Some("material_requirements.allow_material_fallbacks"),
        ));
    }
}

fn validate_base_color_texture_requirement(
    assets: &Assets<impl AssetFetcher>,
    catalog_asset: &AssetCatalogAssetV1,
    asset: &SceneAsset,
    findings: &mut Vec<AssetReadinessFindingV1>,
) {
    if !catalog_asset
        .material_requirements
        .require_base_color_textures
    {
        return;
    }

    for node in asset.nodes() {
        for mesh in node.meshes() {
            let has_base_color_texture = assets
                .material(mesh.material())
                .is_some_and(|material| material.base_color_texture().is_some());
            if !has_base_color_texture {
                findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "base_color_texture_missing",
                    "required base-color texture is missing from one or more mesh materials",
                    "author a baseColorTexture or disable material_requirements.require_base_color_textures",
                    Some(catalog_asset.source.clone()),
                    Some("material_requirements.require_base_color_textures"),
                ));
                return;
            }
        }
    }
}

fn aabb_is_finite(bounds: Aabb) -> bool {
    vec3_is_finite(bounds.min) && vec3_is_finite(bounds.max)
}

fn vec3_is_finite(value: Vec3) -> bool {
    value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}

fn valid_extent(value: f32) -> bool {
    value.is_finite() && value > 0.0
}

fn max_extent(bounds: Aabb) -> f32 {
    let half = bounds.half_extent();
    half.x.max(half.y).max(half.z) * 2.0
}