scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use serde::{Deserialize, Serialize};

use super::{SceneHostCore, SceneHostError, SceneHostErrorCode};
use crate::{
    AntiAliasing, AssetFetcher, AutoExposureConfig, Background, EnvironmentPreset,
    GridFloorOptions, LookupError, PostBloomConfig, ScreenSpaceAmbientOcclusionConfig, Vec3,
};

pub const SCENE_HOST_GROUNDING_SCHEMA_V1: &str = "scena.scene_host_grounding.v1";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SceneSetupPreset {
    ProductStudio,
    CadStudio,
    IndustrialStudio,
}

impl SceneSetupPreset {
    pub const ALL: &'static [Self] =
        &[Self::ProductStudio, Self::CadStudio, Self::IndustrialStudio];

    pub const fn recipe_name(self) -> &'static str {
        match self {
            Self::ProductStudio => "product_studio",
            Self::CadStudio => "cad_studio",
            Self::IndustrialStudio => "industrial_studio",
        }
    }

    pub fn from_recipe_name(name: &str) -> Option<Self> {
        Self::ALL
            .iter()
            .copied()
            .find(|preset| preset.recipe_name() == name)
    }

    pub const fn background(self) -> Background {
        match self {
            Self::ProductStudio => Background::Studio,
            Self::CadStudio => Background::NeutralGray,
            Self::IndustrialStudio => Background::DarkStudio,
        }
    }

    pub const fn environment(self) -> EnvironmentPreset {
        match self {
            Self::ProductStudio | Self::IndustrialStudio => EnvironmentPreset::Studio,
            Self::CadStudio => EnvironmentPreset::NeutralStudio,
        }
    }

    pub const fn auto_exposure(self) -> AutoExposureConfig {
        match self {
            Self::ProductStudio => AutoExposureConfig::product_studio(),
            Self::CadStudio => AutoExposureConfig::mixed(),
            Self::IndustrialStudio => AutoExposureConfig::indoor(),
        }
    }

    pub fn grid_options(self) -> GridFloorOptions {
        match self {
            Self::ProductStudio => GridFloorOptions::new()
                .padding(0.18)
                .line_spacing(0.08)
                .line_width_px(4.0)
                .color(crate::Color::from_srgb_u8(58, 62, 70))
                .line_color(crate::Color::from_srgb_u8(83, 91, 104))
                .roughness(0.88),
            Self::CadStudio => GridFloorOptions::new()
                .padding(0.10)
                .line_spacing(0.05)
                .line_width_px(3.8)
                .color(crate::Color::from_srgb_u8(214, 218, 224))
                .line_color(crate::Color::from_srgb_u8(150, 158, 170))
                .roughness(0.92),
            Self::IndustrialStudio => GridFloorOptions::new()
                .padding(0.22)
                .line_spacing(0.12)
                .line_width_px(4.0)
                .color(crate::Color::from_srgb_u8(39, 44, 54))
                .line_color(crate::Color::from_srgb_u8(73, 84, 101))
                .roughness(0.94),
        }
    }

    pub const fn grid_reflection_strength(self) -> Option<f64> {
        match self {
            Self::ProductStudio => Some(0.32),
            Self::IndustrialStudio => Some(0.18),
            Self::CadStudio => None,
        }
    }

    pub fn ssao(self) -> ScreenSpaceAmbientOcclusionConfig {
        match self {
            Self::ProductStudio => ScreenSpaceAmbientOcclusionConfig::new(4, 0.42, 0.025),
            Self::CadStudio => ScreenSpaceAmbientOcclusionConfig::new(3, 0.28, 0.02),
            Self::IndustrialStudio => ScreenSpaceAmbientOcclusionConfig::new(4, 0.36, 0.03),
        }
    }

    pub const fn anti_aliasing(self) -> AntiAliasing {
        match self {
            Self::ProductStudio | Self::CadStudio | Self::IndustrialStudio => AntiAliasing::Fxaa,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SceneHostGroundingReportV1 {
    pub schema: String,
    pub target: u64,
    pub floor_handles: Vec<u64>,
    pub floor_receiver: bool,
    pub ssao_enabled: bool,
    pub active_paths: Vec<SceneHostGroundingPathV1>,
    pub fallbacks: Vec<SceneHostGroundingFallbackV1>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SceneHostGroundingPathV1 {
    FloorReceiver,
    ScreenSpaceAmbientOcclusion,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SceneHostGroundingFallbackV1 {
    pub code: String,
    pub severity: String,
    pub message: String,
    pub help: String,
}

impl<F: AssetFetcher> SceneHostCore<F> {
    pub fn apply_scene_setup_preset_renderer(&mut self, preset: SceneSetupPreset) {
        self.renderer.set_background(preset.background());
        if self.renderer.auto_exposure().is_none() {
            self.renderer.set_auto_exposure(preset.auto_exposure());
        }
        if self.renderer.screen_space_ambient_occlusion().is_none() {
            self.renderer
                .set_screen_space_ambient_occlusion(Some(preset.ssao()));
        }
    }

    pub fn apply_product_studio_visuals(&mut self, background: &str) -> Result<(), SceneHostError> {
        let background = scene_host_background(background)?;
        let environment = self.assets.default_environment();
        self.renderer.set_environment(environment);
        self.apply_scene_setup_preset_renderer(SceneSetupPreset::ProductStudio);
        self.renderer.set_background(background);
        self.renderer
            .set_anti_aliasing(SceneSetupPreset::ProductStudio.anti_aliasing());
        self.renderer.set_bloom(Some(PostBloomConfig::subtle()));
        let lights = self.scene.add_studio_lighting()?;
        self.register_node(lights.key);
        self.register_node(lights.fill);
        self.register_node(lights.rim);
        Ok(())
    }

    pub fn apply_product_grounding_preset(
        &mut self,
        target: u64,
        background: &str,
    ) -> Result<SceneHostGroundingReportV1, SceneHostError> {
        self.ground_node_to_y_zero(target)?;
        self.apply_product_studio_visuals(background)?;
        let floor_handles = self.add_product_grid_floor_under_node(target)?;
        let floor_receiver = !floor_handles.is_empty();
        let ssao_enabled = self.renderer.screen_space_ambient_occlusion().is_some();
        let mut active_paths = Vec::new();
        if floor_receiver {
            active_paths.push(SceneHostGroundingPathV1::FloorReceiver);
        }
        if ssao_enabled {
            active_paths.push(SceneHostGroundingPathV1::ScreenSpaceAmbientOcclusion);
        }
        Ok(SceneHostGroundingReportV1 {
            schema: SCENE_HOST_GROUNDING_SCHEMA_V1.to_owned(),
            target,
            floor_handles,
            floor_receiver,
            ssao_enabled,
            active_paths,
            fallbacks: grounding_fallbacks(ssao_enabled),
        })
    }

    pub fn apply_product_grounding_preset_json(
        &mut self,
        target: u64,
        background: &str,
    ) -> Result<String, SceneHostError> {
        let report = self.apply_product_grounding_preset(target, background)?;
        serde_json::to_string(&report).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::Inspect,
                format!("grounding report serialization failed: {error}"),
            )
        })
    }

    pub fn add_product_grid_floor_under_node(
        &mut self,
        node: u64,
    ) -> Result<Vec<u64>, SceneHostError> {
        let node = self.resolve_node(node)?;
        let bounds = self
            .scene
            .node_world_bounds(node, &self.assets)?
            .ok_or(LookupError::ImportHasNoBounds)?;
        let floor = self.scene.add_grid_floor(
            &self.assets,
            GridFloorOptions::new()
                .under_bounds(bounds)
                .padding(0.24)
                .line_spacing(0.08),
        )?;
        Ok(vec![
            self.register_node(floor.slab),
            self.register_node(floor.grid),
        ])
    }

    fn ground_node_to_y_zero(&mut self, node: u64) -> Result<(), SceneHostError> {
        let node_key = self.resolve_node(node)?;
        let bounds = self
            .scene
            .node_world_bounds(node_key, &self.assets)?
            .ok_or(LookupError::ImportHasNoBounds)?;
        let drop_y = -bounds.min.y;
        if drop_y.abs() <= 1.0e-6 {
            return Ok(());
        }
        let mut world = self
            .scene
            .world_transform(node_key)
            .ok_or(LookupError::NodeNotFound(node_key))?;
        world.translation += Vec3::new(0.0, drop_y, 0.0);
        self.scene.align_to(node_key, world)?;
        Ok(())
    }
}

fn grounding_fallbacks(ssao_enabled: bool) -> Vec<SceneHostGroundingFallbackV1> {
    let mut fallbacks = Vec::new();
    if ssao_enabled {
        fallbacks.push(SceneHostGroundingFallbackV1 {
            code: "ssao_is_ambient_occlusion".to_owned(),
            severity: "info".to_owned(),
            message: "SSAO darkens depth contact edges but is not a drop-shadow substitute"
                .to_owned(),
            help: "use the report active_paths to distinguish floor receiver, SSAO, and shadow receiver claims"
                .to_owned(),
        });
    } else {
        fallbacks.push(SceneHostGroundingFallbackV1 {
            code: "ssao_unavailable".to_owned(),
            severity: "warning".to_owned(),
            message: "screen-space ambient occlusion is not active for this grounding preset"
                .to_owned(),
            help:
                "check backend capabilities or enable SSAO before relying on contact-edge darkening"
                    .to_owned(),
        });
    }
    fallbacks
}

fn scene_host_background(background: &str) -> Result<Background, SceneHostError> {
    match background {
        "studio_neutral" | "dark_studio" => Ok(Background::DarkStudio),
        "studio" => Ok(Background::Studio),
        "neutral_gray" => Ok(Background::NeutralGray),
        "black" => Ok(Background::Black),
        "white" => Ok(Background::White),
        other => Err(SceneHostError::new(
            SceneHostErrorCode::InvalidInput,
            format!("unsupported SceneHost product studio background {other}"),
        )),
    }
}