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::BTreeMap;

use super::common::{DiagnosticPathExt, authored_color};
use super::transform::{TransformResolutionInput, transform_from_recipe};
use crate::assets::DefaultAssetFetcher;
use crate::scene::recipe::{
    SceneRecipeBuildTargetV1, SceneRecipeColorV1, SceneRecipeDiagnosticV1, SceneRecipeLightV1,
};
use crate::scene_host::SceneHostCore;
use crate::{Angle, AreaLight, AreaLightShape, Color, DirectionalLight, PointLight, SpotLight};

use super::super::error_diagnostic;

pub(in crate::scene_host::recipe) fn build_authored_lights(
    host: &mut SceneHostCore<DefaultAssetFetcher>,
    colors: &BTreeMap<String, SceneRecipeColorV1>,
    recipes: &[SceneRecipeLightV1],
    manifest: &mut Vec<SceneRecipeBuildTargetV1>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let root = host.scene.root();
    let root_handle = host.root_handle();
    for (index, recipe) in recipes.iter().enumerate() {
        let path = format!("$.lights[{index}]");
        if recipe.kind == "studio_rig" {
            if let Some(preset) = recipe.preset.as_deref()
                && preset != "studio_rig"
            {
                diagnostics.push(*invalid_light_preset(
                    format!("{path}.preset"),
                    format!("preset '{preset}' is not valid for studio_rig lights"),
                    "use studio_rig or omit preset for the default studio rig",
                ));
                continue;
            }
            let handles =
                match host.scene.add_studio_lighting() {
                    Ok(handles) => handles,
                    Err(error) => {
                        diagnostics.push(error_diagnostic(
                        &path,
                        "light_create_failed",
                        format!("failed to create studio lighting rig '{}': {error}", recipe.id),
                        "check scene light capacity and report persistent failures as a scena bug",
                    ));
                        continue;
                    }
                };
            for (suffix, node) in [
                ("key", handles.key),
                ("fill", handles.fill),
                ("rim", handles.rim),
            ] {
                let handle = host.register_node(node);
                manifest.push(SceneRecipeBuildTargetV1 {
                    id: format!("{}.{}", recipe.id, suffix),
                    handle,
                    kind: "light".to_owned(),
                    parent: Some(root_handle),
                    name: Some(format!("{} {suffix}", recipe.id)),
                    active: None,
                });
            }
            continue;
        }
        let empty_nodes = BTreeMap::new();
        let empty_imports = BTreeMap::new();
        let transform = match transform_from_recipe(
            recipe.transform.as_ref(),
            TransformResolutionInput {
                node_keys: &empty_nodes,
                imports: &empty_imports,
                parent: Some(root),
                current_bounds: None,
            },
            host,
        ) {
            Ok(transform) => transform,
            Err(diagnostic) => {
                diagnostics.push((*diagnostic).with_path(format!("{path}.transform")));
                continue;
            }
        };
        let color = match recipe.color.as_deref() {
            Some(color) => match authored_color(colors, color) {
                Ok(color) => Some(color),
                Err(diagnostic) => {
                    diagnostics.push((*diagnostic).with_path(format!("{path}.color")));
                    continue;
                }
            },
            None => None,
        };
        let node = match recipe.kind.as_str() {
            "directional" => match authored_directional_light(recipe, color, &path) {
                Ok(light) => host
                    .scene
                    .directional_light(light)
                    .parent(root)
                    .transform(transform)
                    .add(),
                Err(diagnostic) => {
                    diagnostics.push(*diagnostic);
                    continue;
                }
            },
            "point" => match authored_point_light(recipe, color, &path) {
                Ok(light) => host
                    .scene
                    .point_light(light)
                    .parent(root)
                    .transform(transform)
                    .add(),
                Err(diagnostic) => {
                    diagnostics.push(*diagnostic);
                    continue;
                }
            },
            "spot" => match authored_spot_light(recipe, color, &path) {
                Ok(light) => host
                    .scene
                    .spot_light(light)
                    .parent(root)
                    .transform(transform)
                    .add(),
                Err(diagnostic) => {
                    diagnostics.push(*diagnostic);
                    continue;
                }
            },
            "area" => match authored_area_light(recipe, color, &path) {
                Ok(light) => host
                    .scene
                    .area_light(light)
                    .parent(root)
                    .transform(transform)
                    .add(),
                Err(diagnostic) => {
                    diagnostics.push(*diagnostic);
                    continue;
                }
            },
            kind => {
                diagnostics.push(error_diagnostic(
                    &path,
                    "unsupported_feature",
                    format!("light kind '{kind}' is not supported"),
                    "use directional, point, spot, or area",
                ));
                continue;
            }
        };
        let node = match node {
            Ok(node) => node,
            Err(error) => {
                diagnostics.push(error_diagnostic(
                    &path,
                    "light_create_failed",
                    format!("failed to create light '{}': {error}", recipe.id),
                    "check the light transform and parent",
                ));
                continue;
            }
        };
        let handle = host.register_node(node);
        manifest.push(SceneRecipeBuildTargetV1 {
            id: recipe.id.clone(),
            handle,
            kind: "light".to_owned(),
            parent: Some(root_handle),
            name: None,
            active: None,
        });
    }
}

fn authored_directional_light(
    recipe: &SceneRecipeLightV1,
    color: Option<Color>,
    path: &str,
) -> Result<DirectionalLight, Box<SceneRecipeDiagnosticV1>> {
    let mut light = match recipe.preset.as_deref() {
        Some("sun") => DirectionalLight::sun(),
        Some("key") => DirectionalLight::key_light(),
        Some("fill") => DirectionalLight::fill_light(),
        Some("rim") => DirectionalLight::rim_light(),
        Some(preset) => {
            return Err(invalid_light_preset(
                format!("{path}.preset"),
                format!("preset '{preset}' is not valid for directional lights"),
                "use sun, key, fill, or rim",
            ));
        }
        None => DirectionalLight::default(),
    };
    if let Some(color) = color {
        light = light.with_color(color);
    }
    if let Some(lux) = recipe.illuminance_lux {
        light = light.with_illuminance_lux(lux as f32);
    }
    Ok(light)
}

fn authored_point_light(
    recipe: &SceneRecipeLightV1,
    color: Option<Color>,
    path: &str,
) -> Result<PointLight, Box<SceneRecipeDiagnosticV1>> {
    let mut light = match recipe.preset.as_deref() {
        Some("softbox") => PointLight::softbox(),
        Some("bulb_warm") => PointLight::bulb_warm(),
        Some("bulb_cool") => PointLight::bulb_cool(),
        Some(preset) => {
            return Err(invalid_light_preset(
                format!("{path}.preset"),
                format!("preset '{preset}' is not valid for point lights"),
                "use softbox, bulb_warm, or bulb_cool",
            ));
        }
        None => PointLight::default(),
    };
    if let Some(color) = color {
        light = light.with_color(color);
    }
    if let Some(intensity) = recipe.intensity_candela {
        light = light.with_intensity_candela(intensity as f32);
    }
    if let Some(range) = recipe.range {
        light = light.with_range(range as f32);
    }
    Ok(light)
}

fn authored_spot_light(
    recipe: &SceneRecipeLightV1,
    color: Option<Color>,
    path: &str,
) -> Result<SpotLight, Box<SceneRecipeDiagnosticV1>> {
    if recipe.preset.is_some() {
        return Err(Box::new(error_diagnostic(
            format!("{path}.preset"),
            "unsupported_feature",
            "spot light presets are not supported",
            "omit preset and set spot light intensity, range, and cone angles explicitly",
        )));
    }
    let mut light = SpotLight::default();
    if let Some(color) = color {
        light = light.with_color(color);
    }
    if let Some(intensity) = recipe.intensity_candela {
        light = light.with_intensity_candela(intensity as f32);
    }
    if let Some(range) = recipe.range {
        light = light.with_range(range as f32);
    }
    if let Some(inner) = recipe.inner_cone_degrees {
        light = light.with_inner_cone_angle(Angle::from_degrees(inner as f32));
    }
    if let Some(outer) = recipe.outer_cone_degrees {
        light = light.with_outer_cone_angle(Angle::from_degrees(outer as f32));
    }
    Ok(light)
}

fn authored_area_light(
    recipe: &SceneRecipeLightV1,
    color: Option<Color>,
    path: &str,
) -> Result<AreaLight, Box<SceneRecipeDiagnosticV1>> {
    let mut light = match recipe.preset.as_deref() {
        Some("softbox") => AreaLight::softbox(),
        Some(preset) => {
            return Err(invalid_light_preset(
                format!("{path}.preset"),
                format!("preset '{preset}' is not valid for area lights"),
                "use softbox",
            ));
        }
        None => AreaLight::default(),
    };
    if let Some(color) = color {
        light = light.with_color(color);
    }
    if let Some(luminous_flux) = recipe.luminous_flux_lumens {
        light = light.with_luminous_flux_lumens(luminous_flux as f32);
    }
    if let Some(range) = recipe.range {
        light = light.with_range(range as f32);
    }
    if let Some(shape) = authored_area_shape(recipe, path)? {
        light = light.with_shape(shape);
    }
    Ok(light)
}

fn authored_area_shape(
    recipe: &SceneRecipeLightV1,
    path: &str,
) -> Result<Option<AreaLightShape>, Box<SceneRecipeDiagnosticV1>> {
    match recipe.shape.as_deref() {
        Some("rect") => {
            if recipe.preset.as_deref() == Some("softbox")
                && recipe.width.is_none()
                && recipe.height.is_none()
            {
                Ok(None)
            } else {
                Ok(Some(AreaLightShape::rect(
                    recipe.width.unwrap_or(1.0) as f32,
                    recipe.height.unwrap_or(1.0) as f32,
                )))
            }
        }
        Some("disc") => Ok(Some(AreaLightShape::disc(
            recipe.radius.unwrap_or(0.5) as f32
        ))),
        Some("sphere") => Ok(Some(AreaLightShape::sphere(
            recipe.radius.unwrap_or(0.5) as f32
        ))),
        Some(shape) => Err(Box::new(error_diagnostic(
            format!("{path}.shape"),
            "invalid_area_light_shape",
            format!("area light shape '{shape}' is not supported"),
            "use rect, disc, or sphere",
        ))),
        None => {
            if recipe.width.is_some() || recipe.height.is_some() {
                Ok(Some(AreaLightShape::rect(
                    recipe.width.unwrap_or(1.0) as f32,
                    recipe.height.unwrap_or(1.0) as f32,
                )))
            } else if let Some(radius) = recipe.radius {
                Ok(Some(AreaLightShape::disc(radius as f32)))
            } else {
                Ok(None)
            }
        }
    }
}

fn invalid_light_preset(
    path: String,
    message: String,
    help: &'static str,
) -> Box<SceneRecipeDiagnosticV1> {
    Box::new(error_diagnostic(
        path,
        "invalid_light_preset",
        message,
        help,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn area_recipe_with_softbox_shape() -> SceneRecipeLightV1 {
        SceneRecipeLightV1 {
            id: "softbox".to_owned(),
            kind: "area".to_owned(),
            preset: Some("softbox".to_owned()),
            shape: Some("rect".to_owned()),
            color: None,
            illuminance_lux: None,
            intensity_candela: None,
            luminous_flux_lumens: None,
            range: None,
            width: None,
            height: None,
            radius: None,
            inner_cone_degrees: None,
            outer_cone_degrees: None,
            transform: None,
        }
    }

    #[test]
    fn softbox_rect_shape_without_dimensions_preserves_preset_extent() {
        let recipe = area_recipe_with_softbox_shape();

        let light = authored_area_light(&recipe, None, "$.lights[0]").expect("softbox builds");

        assert_eq!(
            light.shape(),
            AreaLightShape::rect(1.2, 0.6),
            "shape:\"rect\" without dimensions must not silently overwrite the softbox preset extent"
        );
    }
}