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, AssetPath, Assets};

use super::checks::{
    finding, validate_catalog_identity, validate_declared_coordinate_system,
    validate_declared_units, validate_load_report, validate_preview,
};
use super::loaded::validate_loaded_asset;
use super::{
    ASSET_READINESS_REPORT_SCHEMA_V1, AssetCatalogAssetV1, AssetCatalogV1,
    AssetReadinessAssetReportV1, AssetReadinessFindingV1, AssetReadinessReportV1,
    AssetReadinessSeverityV1, AssetReadinessSummaryV1,
};

impl<F: AssetFetcher> Assets<F> {
    pub async fn validate_asset_catalog(&self, catalog: &AssetCatalogV1) -> AssetReadinessReportV1 {
        let mut asset_reports = Vec::with_capacity(catalog.assets.len());
        for asset in &catalog.assets {
            asset_reports.push(self.validate_catalog_asset(asset).await);
        }

        let total_assets = asset_reports.len();
        let ready_assets = asset_reports.iter().filter(|asset| asset.ok).count();
        let failed_assets = total_assets - ready_assets;
        let error_count = asset_reports
            .iter()
            .flat_map(|asset| &asset.findings)
            .filter(|finding| finding.severity == AssetReadinessSeverityV1::Error)
            .count();
        let warning_count = asset_reports
            .iter()
            .flat_map(|asset| &asset.findings)
            .filter(|finding| finding.severity == AssetReadinessSeverityV1::Warning)
            .count();
        AssetReadinessReportV1 {
            schema: ASSET_READINESS_REPORT_SCHEMA_V1.to_owned(),
            ok: error_count == 0,
            summary: AssetReadinessSummaryV1 {
                total_assets,
                ready_assets,
                failed_assets,
                error_count,
                warning_count,
            },
            assets: asset_reports,
        }
    }

    async fn validate_catalog_asset(
        &self,
        catalog_asset: &AssetCatalogAssetV1,
    ) -> AssetReadinessAssetReportV1 {
        let mut findings = Vec::new();
        validate_catalog_identity(catalog_asset, &mut findings);
        validate_declared_units(catalog_asset, &mut findings);
        validate_declared_coordinate_system(catalog_asset, &mut findings);
        let mut preview = validate_preview(catalog_asset, &mut findings);
        self.validate_preview_fetch(&mut preview, &mut findings)
            .await;

        for required_file in &catalog_asset.required_files {
            self.validate_required_file(required_file, &mut findings)
                .await;
        }

        let mut geometry = None;
        let mut asset_load_report = None;
        let mut material_fallbacks = Vec::new();
        if !catalog_asset.source.trim().is_empty() {
            match self
                .load_scene_with_report(catalog_asset.source.as_str())
                .await
            {
                Ok(report) => {
                    let asset = report.asset();
                    validate_loaded_asset(self, catalog_asset, asset, &mut findings);
                    let schema_report = report.to_schema_report();
                    validate_load_report(&schema_report, &mut findings);
                    geometry = Some(schema_report.geometry.clone());
                    material_fallbacks = schema_report.material_fallbacks.clone();
                    asset_load_report = Some(schema_report);
                }
                Err(error) => findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "load_failed",
                    format!("asset '{}' did not load: {error}", catalog_asset.id),
                    "fix the source path or the underlying asset error before catalog approval",
                    Some(catalog_asset.source.clone()),
                    Some("source"),
                )),
            }
        }

        let ok = !findings
            .iter()
            .any(|finding| finding.severity == AssetReadinessSeverityV1::Error);
        AssetReadinessAssetReportV1 {
            id: catalog_asset.id.clone(),
            display_name: catalog_asset.display_name.clone(),
            source: catalog_asset.source.clone(),
            ok,
            declared_units: catalog_asset.declared_units.clone(),
            source_coordinate_system: catalog_asset.source_coordinate_system.clone(),
            preview,
            geometry,
            asset_load_report,
            material_fallbacks,
            findings,
        }
    }

    async fn validate_required_file(
        &self,
        required_file: &str,
        findings: &mut Vec<AssetReadinessFindingV1>,
    ) {
        if required_file.trim().is_empty() {
            findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_file_empty",
                "required file path must not be empty",
                "remove the empty entry or supply a fetchable path",
                None,
                Some("required_files"),
            ));
            return;
        }
        let path = AssetPath::from(required_file.to_owned());
        if let Err(error) = self.fetcher.fetch(&path).await {
            findings.push(finding(
                AssetReadinessSeverityV1::Error,
                "required_file_missing",
                format!("required file '{required_file}' is unavailable: {error}"),
                "serve the required file from the same asset source root or remove it from the manifest",
                Some(required_file.to_owned()),
                Some("required_files"),
            ));
        }
    }

    async fn validate_preview_fetch(
        &self,
        preview: &mut Option<super::AssetReadinessPreviewV1>,
        findings: &mut Vec<AssetReadinessFindingV1>,
    ) {
        let Some(preview) = preview.as_mut() else {
            return;
        };
        if preview.kind != "image" {
            return;
        }
        let Some(path) = preview.path.as_deref() else {
            return;
        };
        let path = AssetPath::from(path.to_owned());
        match self.fetcher.fetch(&path).await {
            Ok(bytes) if !bytes.is_empty() => {
                preview.status = "fetched".to_owned();
            }
            Ok(_) => {
                preview.status = "empty".to_owned();
                findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "preview_empty",
                    format!("preview image '{}' is empty", path.as_str()),
                    "replace the preview image with non-empty PNG/JPEG/WebP bytes",
                    Some(path.as_str().to_owned()),
                    Some("preview.path"),
                ));
            }
            Err(error) => {
                preview.status = "missing".to_owned();
                findings.push(finding(
                    AssetReadinessSeverityV1::Error,
                    "preview_fetch_failed",
                    format!("preview image '{}' is unavailable: {error}", path.as_str()),
                    "serve the preview image from the configured AssetFetcher or mark it generated",
                    Some(path.as_str().to_owned()),
                    Some("preview.path"),
                ));
            }
        }
    }
}