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, Deserializer, Serialize, Serializer};

use crate::geometry::Aabb;

use super::view_math::transform_aabb;
use super::{Transform, Vec3};

pub const SCENE_PLACEMENT_RESULT_SCHEMA_V1: &str = "scena.placement_result.v1";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScenePlacementResultV1 {
    pub schema: String,
    pub ok: bool,
    pub verb: String,
    pub import_id: String,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_transform_option",
        deserialize_with = "deserialize_transform_option"
    )]
    pub transform: Option<Transform>,
    pub diagnostics: Vec<ScenePlacementDiagnosticV1>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScenePlacementDiagnosticV1 {
    pub code: String,
    pub severity: String,
    pub path: String,
    pub message: String,
    pub help: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
    #[serde(default)]
    pub auto_fixable: bool,
}

impl ScenePlacementResultV1 {
    pub fn success(
        import_id: impl Into<String>,
        verb: impl Into<String>,
        transform: Transform,
    ) -> Self {
        Self {
            schema: SCENE_PLACEMENT_RESULT_SCHEMA_V1.to_owned(),
            ok: true,
            verb: verb.into(),
            import_id: import_id.into(),
            transform: Some(round_transform(transform)),
            diagnostics: Vec::new(),
        }
    }

    pub fn failure(
        import_id: impl Into<String>,
        verb: impl Into<String>,
        diagnostic: ScenePlacementDiagnosticV1,
    ) -> Self {
        Self {
            schema: SCENE_PLACEMENT_RESULT_SCHEMA_V1.to_owned(),
            ok: false,
            verb: verb.into(),
            import_id: import_id.into(),
            transform: None,
            diagnostics: vec![diagnostic],
        }
    }
}

impl ScenePlacementDiagnosticV1 {
    pub fn new(
        code: impl Into<String>,
        path: impl Into<String>,
        message: impl Into<String>,
        help: impl Into<String>,
    ) -> Self {
        Self {
            code: code.into(),
            severity: "error".to_owned(),
            path: path.into(),
            message: message.into(),
            help: help.into(),
            suggestion: None,
            auto_fixable: false,
        }
    }

    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
        self.suggestion = Some(suggestion.into());
        self
    }
}

pub fn placement_center_transform(bounds: Aabb, current: Transform, target: Vec3) -> Transform {
    let world_bounds = transform_aabb(bounds, current);
    current.with_translation(current.translation + (target - world_bounds.center()))
}

pub fn placement_ground_transform(bounds: Aabb, current: Transform, ground_y: f32) -> Transform {
    let world_bounds = transform_aabb(bounds, current);
    current
        .with_translation(current.translation + Vec3::new(0.0, ground_y - world_bounds.min.y, 0.0))
}

pub fn placement_look_at_transform(
    current: Transform,
    target: Vec3,
    up: Vec3,
) -> Result<Transform, Box<ScenePlacementDiagnosticV1>> {
    if !vec3_is_finite(current.translation) || !vec3_is_finite(target) || !vec3_is_finite(up) {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_transform",
            "$.verb.look_at",
            "look_at requires finite source translation, target, and up vectors",
            "pass finite --target and --up vectors",
        )));
    }
    if (target - current.translation).length_squared() <= f32::EPSILON {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "degenerate_look_at",
            "$.verb.look_at",
            "look_at target must differ from the source translation",
            "choose a target point or target import away from the source",
        )));
    }
    Ok(current.looking_at(target, up))
}

pub fn placement_align_to_feature_transform(
    current: Transform,
    source_feature: Transform,
    target_feature: Transform,
) -> Result<Transform, Box<ScenePlacementDiagnosticV1>> {
    validate_feature_transform(source_feature, "$.source")?;
    validate_feature_transform(target_feature, "$.target")?;
    if !vec3_is_finite(current.scale) || current.scale.abs().min_element() <= f32::EPSILON {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "non_invertible_transform",
            "$.imports[].transform",
            "source import transform must have finite non-zero scale",
            "use a source transform with non-zero finite scale",
        )));
    }

    let source_rotation = normalized_quat(source_feature.rotation, "$.source.rotation")?;
    let target_rotation = normalized_quat(target_feature.rotation, "$.target.rotation")?;
    let rotation = (target_rotation * source_rotation.inverse()).normalize();
    let translation =
        target_feature.translation - rotation * (source_feature.translation * current.scale);
    Ok(Transform {
        translation,
        rotation,
        scale: current.scale,
    })
}

pub fn placement_place_on_feature_transform(
    current: Transform,
    source_feature: Transform,
    target_feature: Transform,
) -> Result<Transform, Box<ScenePlacementDiagnosticV1>> {
    validate_feature_transform(source_feature, "$.source")?;
    validate_feature_transform(target_feature, "$.target")?;
    if !vec3_is_finite(current.translation) || !vec3_is_finite(current.scale) {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_transform",
            "$.imports[].transform",
            "source import transform must have finite translation and scale",
            "use a finite source transform",
        )));
    }
    let source_world = current.translation
        + current.rotation.normalize() * (source_feature.translation * current.scale);
    Ok(current.with_translation(current.translation + (target_feature.translation - source_world)))
}

pub fn placement_fit_to_size_transform(
    bounds: Aabb,
    current: Transform,
    min_size: Option<f32>,
    max_size: Option<f32>,
) -> Result<Transform, Box<ScenePlacementDiagnosticV1>> {
    let world_bounds = transform_aabb(bounds, current);
    let extent = world_bounds.max - world_bounds.min;
    let current_size = extent.x.max(extent.y).max(extent.z);
    if !current_size.is_finite() || current_size <= f32::EPSILON {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_bounds",
            "$.imports[].uri",
            "import bounds must have finite non-zero extent for fit_to_size",
            "use an asset with renderable bounds or choose center/ground instead",
        )));
    }

    if min_size.is_none() && max_size.is_none() {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_size_range",
            "$.verb.fit_to_size",
            "fit_to_size requires --min-size, --max-size, or both",
            "pass a target size range for fit_to_size",
        )));
    }
    if min_size.is_some_and(|value| !value.is_finite() || value < 0.0)
        || max_size.is_some_and(|value| !value.is_finite() || value <= 0.0)
        || min_size
            .zip(max_size)
            .is_some_and(|(min_size, max_size)| max_size < min_size)
    {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_size_range",
            "$.verb.fit_to_size",
            "fit_to_size requires finite non-negative min and positive max with max >= min",
            "pass --min-size and/or --max-size with a valid positive range",
        )));
    }

    let min_size = min_size.unwrap_or(0.0);
    let max_size = max_size.unwrap_or(f32::INFINITY);
    let scale_factor = if current_size > max_size {
        max_size / current_size
    } else if current_size < min_size {
        min_size / current_size
    } else {
        1.0
    };
    Ok(Transform {
        scale: current.scale * scale_factor,
        ..current
    })
}

fn validate_feature_transform(
    transform: Transform,
    path: &str,
) -> Result<(), Box<ScenePlacementDiagnosticV1>> {
    if !vec3_is_finite(transform.translation)
        || !vec3_is_finite(transform.scale)
        || !quat_is_finite(transform.rotation)
    {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_feature",
            path,
            "authored feature transform must be finite",
            "fix the authored anchor or connector transform",
        )));
    }
    if transform.scale.abs().min_element() <= f32::EPSILON {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "non_invertible_feature",
            path,
            "authored feature transform must have non-zero scale",
            "fix the authored anchor or connector scale",
        )));
    }
    Ok(())
}

fn normalized_quat(
    rotation: glam::Quat,
    path: &str,
) -> Result<glam::Quat, Box<ScenePlacementDiagnosticV1>> {
    let length_squared = rotation.length_squared();
    if !length_squared.is_finite() || length_squared <= f32::EPSILON {
        return Err(Box::new(ScenePlacementDiagnosticV1::new(
            "invalid_feature",
            path,
            "authored feature rotation must be finite and non-zero",
            "fix the authored anchor or connector rotation",
        )));
    }
    Ok(rotation.normalize())
}

fn vec3_is_finite(value: Vec3) -> bool {
    value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}

fn quat_is_finite(value: glam::Quat) -> bool {
    value.x.is_finite() && value.y.is_finite() && value.z.is_finite() && value.w.is_finite()
}

fn round_transform(transform: Transform) -> Transform {
    Transform {
        translation: round_vec3(transform.translation),
        rotation: glam::Quat::from_xyzw(
            round3_f32(transform.rotation.x),
            round3_f32(transform.rotation.y),
            round3_f32(transform.rotation.z),
            round3_f32(transform.rotation.w),
        ),
        scale: round_vec3(transform.scale),
    }
}

fn round_vec3(value: Vec3) -> Vec3 {
    Vec3::new(
        round3_f32(value.x),
        round3_f32(value.y),
        round3_f32(value.z),
    )
}

fn round3(value: f32) -> f64 {
    if value.is_finite() {
        ((value as f64) * 1000.0).round() / 1000.0
    } else {
        value as f64
    }
}

fn round3_f32(value: f32) -> f32 {
    round3(value) as f32
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
struct StableTransformV1 {
    translation: [f64; 3],
    rotation: [f64; 4],
    scale: [f64; 3],
}

impl From<Transform> for StableTransformV1 {
    fn from(transform: Transform) -> Self {
        Self {
            translation: [
                round3(transform.translation.x),
                round3(transform.translation.y),
                round3(transform.translation.z),
            ],
            rotation: [
                round3(transform.rotation.x),
                round3(transform.rotation.y),
                round3(transform.rotation.z),
                round3(transform.rotation.w),
            ],
            scale: [
                round3(transform.scale.x),
                round3(transform.scale.y),
                round3(transform.scale.z),
            ],
        }
    }
}

impl From<StableTransformV1> for Transform {
    fn from(transform: StableTransformV1) -> Self {
        Self {
            translation: Vec3::new(
                transform.translation[0] as f32,
                transform.translation[1] as f32,
                transform.translation[2] as f32,
            ),
            rotation: glam::Quat::from_xyzw(
                transform.rotation[0] as f32,
                transform.rotation[1] as f32,
                transform.rotation[2] as f32,
                transform.rotation[3] as f32,
            ),
            scale: Vec3::new(
                transform.scale[0] as f32,
                transform.scale[1] as f32,
                transform.scale[2] as f32,
            ),
        }
    }
}

fn serialize_transform_option<S>(
    transform: &Option<Transform>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    transform.map(StableTransformV1::from).serialize(serializer)
}

fn deserialize_transform_option<'de, D>(deserializer: D) -> Result<Option<Transform>, D::Error>
where
    D: Deserializer<'de>,
{
    Option::<StableTransformV1>::deserialize(deserializer)
        .map(|transform| transform.map(Transform::from))
}