scena 1.7.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::diagnostics::LookupError;

use super::{CameraKey, NodeKey, Scene, Transform, Vec3};

pub const SCENE_ANNOTATION_PROJECTION_SCHEMA_V1: &str = "scena.annotation_projection.v1";

#[derive(Debug, Clone, PartialEq)]
pub struct AnnotationAnchor {
    id: String,
    target: AnnotationAnchorTarget,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AnnotationAnchorTarget {
    World { position: Vec3 },
    Node { node: NodeKey, local_offset: Vec3 },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnnotationProjectionReportV1 {
    pub schema: String,
    pub coordinate_space: String,
    pub viewport_width: u32,
    pub viewport_height: u32,
    pub annotations: Vec<AnnotationProjectionV1>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnnotationProjectionV1 {
    pub id: String,
    #[serde(default)]
    pub node_handle: Option<u64>,
    pub x: f32,
    pub y: f32,
    pub visible: bool,
}

impl AnnotationAnchor {
    pub fn world(id: impl Into<String>, position: Vec3) -> Self {
        Self {
            id: id.into(),
            target: AnnotationAnchorTarget::World { position },
        }
    }

    pub fn node(id: impl Into<String>, node: NodeKey, local_offset: Vec3) -> Self {
        Self {
            id: id.into(),
            target: AnnotationAnchorTarget::Node { node, local_offset },
        }
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    pub const fn target(&self) -> AnnotationAnchorTarget {
        self.target
    }

    pub(crate) const fn target_node(&self) -> Option<NodeKey> {
        match self.target {
            AnnotationAnchorTarget::Node { node, .. } => Some(node),
            AnnotationAnchorTarget::World { .. } => None,
        }
    }
}

impl AnnotationProjectionReportV1 {
    pub fn to_schema_json(&self) -> serde_json::Value {
        serde_json::to_value(self).expect("annotation projection report is JSON-serializable")
    }
}

impl Scene {
    pub fn set_annotation_anchor(&mut self, anchor: AnnotationAnchor) -> Result<(), LookupError> {
        if let Some(node) = anchor.target_node()
            && !self.nodes.contains_key(node)
        {
            return Err(LookupError::NodeNotFound(node));
        }

        let changed = self.annotations.get(anchor.id()) != Some(&anchor);
        self.annotations.insert(anchor.id.clone(), anchor);
        if changed {
            self.structure_revision = self.structure_revision.saturating_add(1);
        }
        Ok(())
    }

    pub fn clear_annotation_anchor(&mut self, id: &str) -> bool {
        let removed = self.annotations.remove(id).is_some();
        if removed {
            self.structure_revision = self.structure_revision.saturating_add(1);
        }
        removed
    }

    pub fn annotation_anchor(&self, id: &str) -> Option<&AnnotationAnchor> {
        self.annotations.get(id)
    }

    pub fn annotation_anchors(&self) -> impl Iterator<Item = &AnnotationAnchor> {
        self.annotations.values()
    }

    pub fn annotation_projection_report(
        &self,
        camera: CameraKey,
        viewport_width: u32,
        viewport_height: u32,
    ) -> Result<AnnotationProjectionReportV1, LookupError> {
        if viewport_width == 0 || viewport_height == 0 {
            return Err(LookupError::InvalidViewport {
                width: viewport_width,
                height: viewport_height,
            });
        }
        if !self.cameras.contains_key(camera) {
            return Err(LookupError::CameraNotFound(camera));
        }

        let annotations = self
            .annotations
            .values()
            .map(|anchor| self.project_annotation(anchor, camera, viewport_width, viewport_height))
            .collect::<Result<Vec<_>, _>>()?;

        Ok(AnnotationProjectionReportV1 {
            schema: SCENE_ANNOTATION_PROJECTION_SCHEMA_V1.to_owned(),
            coordinate_space: "viewport_pixels".to_owned(),
            viewport_width,
            viewport_height,
            annotations,
        })
    }

    pub fn annotation_projection_report_with_node_handles(
        &self,
        camera: CameraKey,
        viewport_width: u32,
        viewport_height: u32,
        node_handles: &BTreeMap<NodeKey, u64>,
    ) -> Result<AnnotationProjectionReportV1, LookupError> {
        let mut report =
            self.annotation_projection_report(camera, viewport_width, viewport_height)?;
        for projection in &mut report.annotations {
            projection.node_handle = self
                .annotation_anchor(&projection.id)
                .and_then(|anchor| anchor.target_node())
                .and_then(|node| node_handles.get(&node).copied());
        }
        Ok(report)
    }

    fn project_annotation(
        &self,
        anchor: &AnnotationAnchor,
        camera: CameraKey,
        viewport_width: u32,
        viewport_height: u32,
    ) -> Result<AnnotationProjectionV1, LookupError> {
        let world_position = match anchor.target {
            AnnotationAnchorTarget::World { position } => position,
            AnnotationAnchorTarget::Node { node, local_offset } => {
                let world_transform = self
                    .world_transform(node)
                    .ok_or(LookupError::NodeNotFound(node))?;
                Transform::compose(world_transform, Transform::at(local_offset)).translation
            }
        };

        let projected =
            self.project_world_point(camera, world_position, viewport_width, viewport_height)?;
        let (x, y, visible) = match projected {
            Some(point) => (
                point.x,
                point.y,
                point.ndc_x.abs() <= 1.0 && point.ndc_y.abs() <= 1.0,
            ),
            None => (0.0, 0.0, false),
        };

        Ok(AnnotationProjectionV1 {
            id: anchor.id.clone(),
            node_handle: None,
            x,
            y,
            visible,
        })
    }
}