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::vec3;
use crate::assets::DefaultAssetFetcher;
use crate::geometry::Aabb;
use crate::scene::recipe::{
    SceneRecipeDiagnosticV1, SceneRecipeLookAtTargetV1, SceneRecipeTransformV1,
};
use crate::scene::view_math::transform_aabb;
use crate::scene_host::SceneHostCore;
use crate::{
    NodeKey, Quat, SceneImport, Transform, Vec3, placement_center_transform,
    placement_ground_transform,
};

use super::super::error_diagnostic;

pub(super) struct TransformResolutionInput<'a> {
    pub(super) node_keys: &'a BTreeMap<String, NodeKey>,
    pub(super) imports: &'a BTreeMap<String, u64>,
    pub(super) parent: Option<NodeKey>,
    pub(super) current_bounds: Option<Aabb>,
}

pub(super) fn transform_from_recipe(
    transform: Option<&SceneRecipeTransformV1>,
    context: TransformResolutionInput<'_>,
    host: &SceneHostCore<DefaultAssetFetcher>,
) -> Result<Transform, Box<SceneRecipeDiagnosticV1>> {
    let Some(transform) = transform else {
        return Ok(Transform::IDENTITY);
    };
    match transform {
        SceneRecipeTransformV1::Raw {
            translation,
            rotation,
            scale,
        } => {
            let rotation = Quat::from_xyzw(
                rotation[0] as f32,
                rotation[1] as f32,
                rotation[2] as f32,
                rotation[3] as f32,
            );
            let length_sq = rotation.length_squared();
            if !length_sq.is_finite() || length_sq <= f32::EPSILON {
                return Err(Box::new(error_diagnostic(
                    "$",
                    "invalid_rotation",
                    "raw transform rotation must be a finite non-zero quaternion",
                    "use [0,0,0,1] for identity",
                )));
            }
            Ok(Transform {
                translation: vec3(*translation),
                rotation: rotation.normalize(),
                scale: vec3(*scale),
            })
        }
        SceneRecipeTransformV1::Trs {
            translation,
            rotation_degrees,
            scale,
        } => Ok(Transform::IDENTITY
            .with_translation(vec3(*translation))
            .rotate_x_deg(rotation_degrees[0] as f32)
            .rotate_y_deg(rotation_degrees[1] as f32)
            .rotate_z_deg(rotation_degrees[2] as f32)
            .with_scale(vec3(*scale))),
        SceneRecipeTransformV1::LookAt { eye, target, up } => {
            let target = match target {
                SceneRecipeLookAtTargetV1::Position(position) => vec3(*position),
                SceneRecipeLookAtTargetV1::Node(id) => {
                    let node = context.node_keys.get(id).ok_or_else(|| {
                        Box::new(error_diagnostic(
                            "$",
                            "unknown_node_ref",
                            format!("look_at target references unknown node '{id}'"),
                            "target an authored node id or provide a [x,y,z] position",
                        ))
                    })?;
                    node_target_position(host, *node).ok_or_else(|| {
                        Box::new(error_diagnostic(
                            "$",
                            "node_bounds_missing",
                            format!("look_at target node '{id}' has no position or bounds"),
                            "target a renderable node or provide an explicit [x,y,z] position",
                        ))
                    })?
                }
            };
            let desired_world = Transform::at(vec3(*eye)).looking_at(target, vec3(*up));
            local_from_world(host, context.parent, desired_world)
        }
        SceneRecipeTransformV1::Center {} => {
            let bounds = require_bounds(context.current_bounds, "center")?;
            let current_world = current_world_transform(host, context.parent)?;
            let desired_world = placement_center_transform(bounds, current_world, Vec3::ZERO);
            local_from_world(host, context.parent, desired_world)
        }
        SceneRecipeTransformV1::Ground { plane_y } => {
            let bounds = require_bounds(context.current_bounds, "ground")?;
            let current_world = current_world_transform(host, context.parent)?;
            let desired_world = placement_ground_transform(bounds, current_world, *plane_y as f32);
            local_from_world(host, context.parent, desired_world)
        }
        SceneRecipeTransformV1::FitToSize { size } => {
            let bounds = require_bounds(context.current_bounds, "fit_to_size")?;
            let current_world = current_world_transform(host, context.parent)?;
            let current_bounds = transform_aabb(bounds, current_world);
            let extent = current_bounds.max - current_bounds.min;
            let target = vec3(*size);
            let factors = [
                target.x / extent.x.max(f32::EPSILON),
                target.y / extent.y.max(f32::EPSILON),
                target.z / extent.z.max(f32::EPSILON),
            ];
            if factors
                .into_iter()
                .any(|factor| !factor.is_finite() || factor <= 0.0)
            {
                return Err(Box::new(error_diagnostic(
                    "$",
                    "invalid_bounds",
                    "fit_to_size requires finite non-zero source bounds",
                    "use renderable geometry with non-degenerate bounds",
                )));
            }
            let factor = factors.into_iter().min_by(f32::total_cmp).unwrap_or(1.0);
            let desired_world = Transform {
                scale: current_world.scale * factor,
                ..current_world
            };
            local_from_world(host, context.parent, desired_world)
        }
        SceneRecipeTransformV1::PlaceOn { target, offset } => {
            let bounds = require_bounds(context.current_bounds, "place_on")?;
            let target = context.node_keys.get(target).copied().ok_or_else(|| {
                Box::new(error_diagnostic(
                    "$",
                    "unknown_node_ref",
                    "place_on target references an unknown node",
                    "declare target nodes before nodes that place_on them",
                ))
            })?;
            let target_bounds = host
                .scene
                .node_world_bounds(target, &host.assets)
                .map_err(|error| {
                    Box::new(error_diagnostic(
                        "$",
                        "node_bounds_failed",
                        format!("place_on target bounds could not be resolved: {error}"),
                        "target a renderable node with finite bounds",
                    ))
                })?
                .ok_or_else(|| {
                    Box::new(error_diagnostic(
                        "$",
                        "node_bounds_missing",
                        "place_on target has no renderable bounds",
                        "target a renderable node with finite bounds",
                    ))
                })?;
            let current_world = current_world_transform(host, context.parent)?;
            let source_bounds = transform_aabb(bounds, current_world);
            let source_anchor = Vec3::new(
                source_bounds.center().x,
                source_bounds.min.y,
                source_bounds.center().z,
            );
            let target_anchor = Vec3::new(
                target_bounds.center().x,
                target_bounds.max.y,
                target_bounds.center().z,
            ) + vec3(*offset);
            let desired_world = current_world
                .with_translation(current_world.translation + target_anchor - source_anchor);
            local_from_world(host, context.parent, desired_world)
        }
        SceneRecipeTransformV1::AlignToAnchor { anchor } => {
            let target_world = anchor_world_transform(host, context.imports, anchor)?;
            local_from_world(host, context.parent, target_world)
        }
    }
}

trait TransformScaleExt {
    fn with_scale(self, scale: Vec3) -> Self;
}

impl TransformScaleExt for Transform {
    fn with_scale(mut self, scale: Vec3) -> Self {
        self.scale = scale;
        self
    }
}

fn node_target_position(host: &SceneHostCore<DefaultAssetFetcher>, node: NodeKey) -> Option<Vec3> {
    host.scene
        .node_world_bounds(node, &host.assets)
        .ok()
        .flatten()
        .map(|bounds| bounds.center())
        .or_else(|| {
            host.scene
                .world_transform(node)
                .map(|transform| transform.translation)
        })
}

fn require_bounds(bounds: Option<Aabb>, verb: &str) -> Result<Aabb, Box<SceneRecipeDiagnosticV1>> {
    bounds.ok_or_else(|| {
        Box::new(error_diagnostic(
            "$",
            "node_bounds_missing",
            format!("{verb} transform requires renderable geometry bounds"),
            "use the transform on a renderable node with geometry",
        ))
    })
}

fn current_world_transform(
    host: &SceneHostCore<DefaultAssetFetcher>,
    parent: Option<NodeKey>,
) -> Result<Transform, Box<SceneRecipeDiagnosticV1>> {
    match parent {
        Some(parent) => host.scene.world_transform(parent).ok_or_else(|| {
            Box::new(error_diagnostic(
                "$",
                "unknown_node_ref",
                "transform parent could not be resolved",
                "declare parent nodes before their children",
            ))
        }),
        None => Ok(Transform::IDENTITY),
    }
}

fn local_from_world(
    host: &SceneHostCore<DefaultAssetFetcher>,
    parent: Option<NodeKey>,
    world: Transform,
) -> Result<Transform, Box<SceneRecipeDiagnosticV1>> {
    let Some(parent) = parent else {
        return Ok(world);
    };
    let parent_world = host.scene.world_transform(parent).ok_or_else(|| {
        Box::new(error_diagnostic(
            "$",
            "unknown_node_ref",
            "transform parent could not be resolved",
            "declare parent nodes before their children",
        ))
    })?;
    local_transform_from_parent(parent_world, world).ok_or_else(|| {
        Box::new(error_diagnostic(
            "$",
            "non_invertible_parent_transform",
            "parent transform has a non-finite or zero scale",
            "use a parent transform with finite non-zero scale",
        ))
    })
}

fn local_transform_from_parent(parent: Transform, world: Transform) -> Option<Transform> {
    if ![parent.scale.x, parent.scale.y, parent.scale.z]
        .into_iter()
        .all(|component| component.is_finite() && component.abs() > f32::EPSILON)
    {
        return None;
    }
    let inverse_parent_rotation = parent.rotation.normalize().inverse();
    let local_translation = inverse_parent_rotation * (world.translation - parent.translation);
    Some(Transform {
        translation: local_translation / parent.scale,
        rotation: (inverse_parent_rotation * world.rotation).normalize(),
        scale: world.scale / parent.scale,
    })
}

fn anchor_world_transform(
    host: &SceneHostCore<DefaultAssetFetcher>,
    imports: &BTreeMap<String, u64>,
    anchor: &str,
) -> Result<Transform, Box<SceneRecipeDiagnosticV1>> {
    let (import_id, anchor_name) = anchor.split_once('.').ok_or_else(|| {
        Box::new(error_diagnostic(
            "$",
            "invalid_anchor_ref",
            "anchor ref must be in the form <import_id>.<anchor_name>",
            "target an imported anchor such as machine.mount",
        ))
    })?;
    let import_handle = imports.get(import_id).copied().ok_or_else(|| {
        Box::new(error_diagnostic(
            "$",
            "unknown_import_ref",
            format!("anchor ref references unknown import '{import_id}'"),
            "target an import id declared in imports",
        ))
    })?;
    let import = host.resolve_import(import_handle).map_err(|error| {
        Box::new(error_diagnostic(
            "$",
            "import_resolve_failed",
            format!("import '{import_id}' could not be resolved: {error}"),
            "target a live imported asset",
        ))
    })?;
    anchor_from_import(import, anchor_name).and_then(|anchor| {
        let node_world = host.scene.world_transform(anchor.node()).ok_or_else(|| {
            Box::new(error_diagnostic(
                "$",
                "anchor_node_missing",
                format!("anchor '{anchor_name}' host node could not be resolved"),
                "target an anchor attached to a live import node",
            ))
        })?;
        Ok(Transform::compose(node_world, anchor.transform()))
    })
}

fn anchor_from_import<'a>(
    import: &'a SceneImport,
    anchor_name: &str,
) -> Result<&'a crate::scene::ImportAnchor, Box<SceneRecipeDiagnosticV1>> {
    import.anchor(anchor_name).map_err(|error| {
        Box::new(error_diagnostic(
            "$",
            "anchor_not_found",
            format!("anchor '{anchor_name}' could not be resolved: {error}"),
            "target an anchor declared by the imported asset",
        ))
    })
}