scena 1.0.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
//! Stage C2: connector extras parsing now reads from `gltf::Node::extras()`.

use std::collections::BTreeSet;

use ::gltf::Node;
use serde_json::Value as JsonValue;

use crate::scene::{ConnectorMetadata, ConnectorPolarity, ConnectorRollPolicy, Transform};

use super::transform::parse_node_transform;

#[derive(Debug, Clone, PartialEq)]
pub struct SceneAssetConnector {
    name: String,
    kind: Option<String>,
    allowed_mates: Vec<String>,
    tags: BTreeSet<String>,
    snap_tolerance: Option<f32>,
    clearance_hint: Option<f32>,
    roll_policy: ConnectorRollPolicy,
    polarity: Option<ConnectorPolarity>,
    metadata: Option<ConnectorMetadata>,
    transform: Transform,
    invalid_reason: Option<String>,
}

impl SceneAssetConnector {
    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn kind(&self) -> Option<&str> {
        self.kind.as_deref()
    }

    pub fn allowed_mates(&self) -> Vec<&str> {
        self.allowed_mates.iter().map(String::as_str).collect()
    }

    pub fn tags(&self) -> &BTreeSet<String> {
        &self.tags
    }

    pub const fn snap_tolerance(&self) -> Option<f32> {
        self.snap_tolerance
    }

    pub const fn clearance_hint(&self) -> Option<f32> {
        self.clearance_hint
    }

    pub const fn roll_policy(&self) -> ConnectorRollPolicy {
        self.roll_policy
    }

    pub const fn polarity(&self) -> Option<ConnectorPolarity> {
        self.polarity
    }

    pub const fn metadata(&self) -> Option<&ConnectorMetadata> {
        self.metadata.as_ref()
    }

    pub fn transform(&self) -> Transform {
        self.transform
    }

    pub(crate) fn invalid_reason(&self) -> Option<&str> {
        self.invalid_reason.as_deref()
    }
}

pub(super) fn parse_node_connectors(node: &Node) -> Vec<SceneAssetConnector> {
    let Some(extras) = super::extras_to_value(node.extras()) else {
        return Vec::new();
    };
    extras
        .get("scena")
        .and_then(|scena| scena.get("connectors"))
        .and_then(JsonValue::as_array)
        .map(|connectors| {
            connectors
                .iter()
                .map(|connector| SceneAssetConnector {
                    name: connector
                        .get("name")
                        .and_then(JsonValue::as_str)
                        .unwrap_or_default()
                        .to_string(),
                    kind: connector
                        .get("kind")
                        .and_then(JsonValue::as_str)
                        .map(str::to_string),
                    allowed_mates: parse_string_array(connector, "allowedMates", "allowed_mates"),
                    tags: parse_string_array(connector, "tags", "tags")
                        .into_iter()
                        .collect(),
                    snap_tolerance: parse_non_negative_f32(
                        connector,
                        "snapTolerance",
                        "snap_tolerance",
                    ),
                    clearance_hint: parse_non_negative_f32(
                        connector,
                        "clearanceHint",
                        "clearance_hint",
                    ),
                    roll_policy: parse_roll_policy(connector)
                        .unwrap_or(ConnectorRollPolicy::Preserve),
                    polarity: parse_polarity(connector),
                    metadata: connector
                        .get("metadata")
                        .filter(|metadata| metadata.is_object())
                        .cloned()
                        .map(ConnectorMetadata::new),
                    transform: parse_node_transform(connector),
                    invalid_reason: validate_connector_extras(connector),
                })
                .collect()
        })
        .unwrap_or_default()
}

fn validate_connector_extras(connector: &JsonValue) -> Option<String> {
    match connector.get("name").and_then(JsonValue::as_str) {
        Some(name) if !name.trim().is_empty() => {}
        Some(_) => return Some("connector name must not be empty".to_string()),
        None => return Some("connector name must be a string".to_string()),
    }
    if connector
        .get("kind")
        .and_then(JsonValue::as_str)
        .is_some_and(|kind| kind.trim().is_empty())
    {
        return Some("connector kind must not be empty when present".to_string());
    }
    if let Some(reason) = validate_string_array(connector, "allowedMates", "allowed_mates") {
        return Some(reason);
    }
    if let Some(reason) = validate_string_array(connector, "tags", "tags") {
        return Some(reason);
    }
    if let Some(reason) = validate_non_negative_f32(connector, "snapTolerance", "snap_tolerance") {
        return Some(reason);
    }
    if let Some(reason) = validate_non_negative_f32(connector, "clearanceHint", "clearance_hint") {
        return Some(reason);
    }
    if connector
        .get("rollPolicy")
        .or_else(|| connector.get("roll_policy"))
        .is_some()
        && parse_roll_policy(connector).is_none()
    {
        return Some(
            "connector rollPolicy must be preserve, chooseNearest, or explicitAngle".to_string(),
        );
    }
    if connector.get("polarity").is_some() && parse_polarity(connector).is_none() {
        return Some("connector polarity must be plug, socket, or neutral".to_string());
    }
    if connector
        .get("metadata")
        .is_some_and(|metadata| !metadata.is_object())
    {
        return Some("connector metadata must be a JSON object when present".to_string());
    }
    None
}

fn parse_string_array(connector: &JsonValue, camel: &str, snake: &str) -> Vec<String> {
    connector
        .get(camel)
        .or_else(|| connector.get(snake))
        .and_then(JsonValue::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(JsonValue::as_str)
                .filter(|value| !value.trim().is_empty())
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

fn validate_string_array(connector: &JsonValue, camel: &str, snake: &str) -> Option<String> {
    let field = connector.get(camel).or_else(|| connector.get(snake))?;
    let Some(values) = field.as_array() else {
        return Some(format!(
            "connector {camel} must be an array of non-empty strings"
        ));
    };
    if values
        .iter()
        .any(|value| !matches!(value.as_str(), Some(text) if !text.trim().is_empty()))
    {
        return Some(format!(
            "connector {camel} must be an array of non-empty strings"
        ));
    }
    None
}

fn parse_non_negative_f32(connector: &JsonValue, camel: &str, snake: &str) -> Option<f32> {
    connector
        .get(camel)
        .or_else(|| connector.get(snake))
        .and_then(JsonValue::as_f64)
        .filter(|value| value.is_finite() && *value >= 0.0)
        .map(|value| value as f32)
}

fn validate_non_negative_f32(connector: &JsonValue, camel: &str, snake: &str) -> Option<String> {
    if connector
        .get(camel)
        .or_else(|| connector.get(snake))
        .is_some()
        && parse_non_negative_f32(connector, camel, snake).is_none()
    {
        return Some(format!(
            "connector {camel} must be a finite non-negative number"
        ));
    }
    None
}

fn parse_roll_policy(connector: &JsonValue) -> Option<ConnectorRollPolicy> {
    match connector
        .get("rollPolicy")
        .or_else(|| connector.get("roll_policy"))
        .and_then(JsonValue::as_str)?
    {
        "preserve" => Some(ConnectorRollPolicy::Preserve),
        "chooseNearest" | "choose_nearest" | "choose-nearest" => {
            Some(ConnectorRollPolicy::ChooseNearest)
        }
        "explicitAngle" | "explicit_angle" | "explicit-angle" => {
            Some(ConnectorRollPolicy::ExplicitAngle)
        }
        _ => None,
    }
}

fn parse_polarity(connector: &JsonValue) -> Option<ConnectorPolarity> {
    match connector.get("polarity").and_then(JsonValue::as_str)? {
        "plug" => Some(ConnectorPolarity::Plug),
        "socket" => Some(ConnectorPolarity::Socket),
        "neutral" => Some(ConnectorPolarity::Neutral),
        _ => None,
    }
}