scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use crate::scene::recipe::{RecipeBuildPolicy, SceneRecipeDiagnosticV1};

use super::super::error_diagnostic;

#[derive(Debug, Default)]
pub(in crate::scene_host::recipe) struct RecipeBuildBudget {
    nodes: usize,
    instances: usize,
    vertices: u64,
    indices: u64,
    materials: usize,
    animations: usize,
    animation_channels: usize,
    animation_keyframes: usize,
}

impl RecipeBuildBudget {
    pub(in crate::scene_host::recipe) fn reserve_nodes(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        count: usize,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next = self.nodes.saturating_add(count);
        if next > policy.max_nodes() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next} nodes, exceeding RecipeBuildPolicy max_nodes {}",
                    policy.max_nodes()
                ),
                "reduce imported/authored node count or raise the operator-owned max_nodes policy",
            ));
        }
        self.nodes = next;
        None
    }

    pub(in crate::scene_host::recipe) fn reserve_instances(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        count: usize,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next = self.instances.saturating_add(count);
        if next > policy.max_instances() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next} instances, exceeding RecipeBuildPolicy max_instances {}",
                    policy.max_instances()
                ),
                "reduce imported/authored instance count or raise the operator-owned max_instances policy",
            ));
        }
        self.instances = next;
        None
    }

    pub(in crate::scene_host::recipe) fn reserve_geometry(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        vertices: u64,
        indices: u64,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next_vertices = self.vertices.saturating_add(vertices);
        if next_vertices > policy.max_vertices() as u64 {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next_vertices} vertices, exceeding RecipeBuildPolicy max_vertices {}",
                    policy.max_vertices()
                ),
                "reduce imported/authored geometry detail or raise the operator-owned max_vertices policy",
            ));
        }
        let next_indices = self.indices.saturating_add(indices);
        if next_indices > policy.max_indices() as u64 {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next_indices} indices, exceeding RecipeBuildPolicy max_indices {}",
                    policy.max_indices()
                ),
                "reduce imported/authored geometry detail or raise the operator-owned max_indices policy",
            ));
        }
        self.vertices = next_vertices;
        self.indices = next_indices;
        None
    }

    pub(in crate::scene_host::recipe) fn reserve_materials(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        count: usize,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next = self.materials.saturating_add(count);
        if next > policy.max_materials() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next} materials, exceeding RecipeBuildPolicy max_materials {}",
                    policy.max_materials()
                ),
                "reduce material count or raise the operator-owned max_materials policy",
            ));
        }
        self.materials = next;
        None
    }

    pub(in crate::scene_host::recipe) fn reserve_animation(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        channels: usize,
        keyframes: usize,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next_animations = self.animations.saturating_add(1);
        if next_animations > policy.max_animations() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next_animations} animations, exceeding RecipeBuildPolicy max_animations {}",
                    policy.max_animations()
                ),
                "reduce animation count or raise the operator-owned max_animations policy",
            ));
        }
        let next_channels = self.animation_channels.saturating_add(channels);
        if next_channels > policy.max_animation_channels() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next_channels} animation channels, exceeding RecipeBuildPolicy max_animation_channels {}",
                    policy.max_animation_channels()
                ),
                "reduce animation channel count or raise the operator-owned max_animation_channels policy",
            ));
        }
        let next_keyframes = self.animation_keyframes.saturating_add(keyframes);
        if next_keyframes > policy.max_animation_keyframes() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would contain {next_keyframes} animation keyframes, exceeding RecipeBuildPolicy max_animation_keyframes {}",
                    policy.max_animation_keyframes()
                ),
                "reduce keyframe count or raise the operator-owned max_animation_keyframes policy",
            ));
        }
        self.animations = next_animations;
        self.animation_channels = next_channels;
        self.animation_keyframes = next_keyframes;
        None
    }
}

#[derive(Debug, Default)]
pub(in crate::scene_host::recipe) struct RecipeTextureBudget {
    texture_count: usize,
    decoded_texture_bytes: usize,
}

impl RecipeTextureBudget {
    pub(in crate::scene_host::recipe) fn reserve_texture_uri(
        &mut self,
        policy: &RecipeBuildPolicy,
        recipe_path: &str,
        uri: &str,
        diagnostic_path: impl Into<String>,
    ) -> Result<String, Box<SceneRecipeDiagnosticV1>> {
        let diagnostic_path = diagnostic_path.into();
        self.reserve_texture_slot(policy, &diagnostic_path)?;
        let resolved = policy.resolve_import_uri(recipe_path, uri, diagnostic_path.clone())?;
        self.check_local_texture_budget(policy, &resolved, &diagnostic_path)?;
        Ok(resolved)
    }

    pub(in crate::scene_host::recipe) fn reserve_environment_uri(
        &mut self,
        policy: &RecipeBuildPolicy,
        recipe_path: &str,
        uri: &str,
        diagnostic_path: impl Into<String>,
    ) -> Result<String, Box<SceneRecipeDiagnosticV1>> {
        let diagnostic_path = diagnostic_path.into();
        self.reserve_texture_slot(policy, &diagnostic_path)?;
        let resolved = policy.resolve_import_uri(recipe_path, uri, diagnostic_path.clone())?;
        self.check_local_texture_budget(policy, &resolved, &diagnostic_path)?;
        Ok(resolved)
    }

    pub(in crate::scene_host::recipe) fn reserve_loaded_textures(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
        texture_count: usize,
        decoded_bytes: usize,
    ) -> Option<SceneRecipeDiagnosticV1> {
        let next_count = self.texture_count.saturating_add(texture_count);
        if next_count > policy.max_textures() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe build would reference {next_count} textures, exceeding RecipeBuildPolicy max_textures {}",
                    policy.max_textures()
                ),
                "use fewer textures or raise the operator-owned max_textures policy",
            ));
        }
        let next_bytes = self.decoded_texture_bytes.saturating_add(decoded_bytes);
        if next_bytes > policy.max_texture_bytes() {
            return Some(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "decoded textures would use {next_bytes} bytes, exceeding RecipeBuildPolicy max_texture_bytes {}",
                    policy.max_texture_bytes()
                ),
                "use smaller textures or raise the operator-owned max_texture_bytes policy",
            ));
        }
        self.texture_count = next_count;
        self.decoded_texture_bytes = next_bytes;
        None
    }

    fn reserve_texture_slot(
        &mut self,
        policy: &RecipeBuildPolicy,
        diagnostic_path: &str,
    ) -> Result<(), Box<SceneRecipeDiagnosticV1>> {
        let next_count = self.texture_count.saturating_add(1);
        if next_count > policy.max_textures() {
            return Err(Box::new(error_diagnostic(
                diagnostic_path,
                "policy_violation",
                format!(
                    "recipe references {next_count} textures, exceeding RecipeBuildPolicy max_textures {}",
                    policy.max_textures()
                ),
                "use fewer textures or raise the operator-owned max_textures policy",
            )));
        }
        self.texture_count = next_count;
        Ok(())
    }

    fn check_local_texture_budget(
        &mut self,
        policy: &RecipeBuildPolicy,
        resolved: &str,
        diagnostic_path: &str,
    ) -> Result<(), Box<SceneRecipeDiagnosticV1>> {
        let path = std::path::Path::new(resolved);
        let metadata = match std::fs::metadata(path) {
            Ok(metadata) => metadata,
            Err(_) => return Ok(()),
        };
        if metadata.is_file() {
            let source_bytes = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
            if source_bytes > policy.fetch_byte_limit() {
                return Err(Box::new(error_diagnostic(
                    diagnostic_path,
                    "policy_violation",
                    format!(
                        "texture source is {source_bytes} bytes, exceeding RecipeBuildPolicy fetch_byte_limit {}",
                        policy.fetch_byte_limit()
                    ),
                    "use a smaller texture or raise the operator-owned fetch_byte_limit policy",
                )));
            }
            if source_bytes > policy.max_texture_bytes() {
                return Err(Box::new(error_diagnostic(
                    diagnostic_path,
                    "policy_violation",
                    format!(
                        "texture source is {source_bytes} bytes, exceeding RecipeBuildPolicy max_texture_bytes {}",
                        policy.max_texture_bytes()
                    ),
                    "use a smaller texture or raise the operator-owned max_texture_bytes policy",
                )));
            }
        }

        if let Ok((width, height)) = image::image_dimensions(path) {
            let max_dimension = width.max(height);
            if max_dimension > policy.max_image_dimension() {
                return Err(Box::new(error_diagnostic(
                    diagnostic_path,
                    "policy_violation",
                    format!(
                        "texture dimensions {width}x{height} exceed RecipeBuildPolicy max_image_dimension {}",
                        policy.max_image_dimension()
                    ),
                    "use smaller textures or raise the operator-owned max_image_dimension policy",
                )));
            }
            let decoded_bytes = decoded_rgba8_bytes(width, height)?;
            let next_decoded_bytes = self.decoded_texture_bytes.saturating_add(decoded_bytes);
            if next_decoded_bytes > policy.max_texture_bytes() {
                return Err(Box::new(error_diagnostic(
                    diagnostic_path,
                    "policy_violation",
                    format!(
                        "decoded textures project {next_decoded_bytes} bytes, exceeding RecipeBuildPolicy max_texture_bytes {}",
                        policy.max_texture_bytes()
                    ),
                    "use smaller textures or raise the operator-owned max_texture_bytes policy",
                )));
            }
            self.decoded_texture_bytes = next_decoded_bytes;
        }
        Ok(())
    }
}

fn decoded_rgba8_bytes(width: u32, height: u32) -> Result<usize, Box<SceneRecipeDiagnosticV1>> {
    let bytes = u64::from(width)
        .checked_mul(u64::from(height))
        .and_then(|pixels| pixels.checked_mul(4))
        .ok_or_else(|| {
            Box::new(error_diagnostic(
                "$",
                "policy_violation",
                "texture dimensions overflow decoded-byte projection",
                "use smaller textures before building the recipe",
            ))
        })?;
    usize::try_from(bytes).map_err(|_| {
        Box::new(error_diagnostic(
            "$",
            "policy_violation",
            "texture dimensions exceed this platform's addressable memory",
            "use smaller textures before building the recipe",
        ))
    })
}