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

use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::visual_patch::{VISUAL_PATCH_SCHEMA_V1, VisualPatchResultV1, VisualPatchV1};
use super::{SceneHostCore, SceneHostError, SceneHostErrorCode};
use crate::AssetFetcher;

pub const PRODUCT_OPTIONS_SCHEMA_V1: &str = "scena.product_options.v1";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProductOptionsV1 {
    pub schema: String,
    #[serde(default)]
    pub groups: Vec<ProductOptionGroupV1>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProductOptionGroupV1 {
    pub id: String,
    pub label: String,
    #[serde(default)]
    pub active: Option<String>,
    #[serde(default)]
    pub options: Vec<ProductOptionV1>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProductOptionV1 {
    pub id: String,
    pub label: String,
    pub patch: VisualPatchV1,
    #[serde(default)]
    pub metadata: Option<Value>,
}

impl ProductOptionsV1 {
    pub fn empty() -> Self {
        Self {
            schema: PRODUCT_OPTIONS_SCHEMA_V1.to_owned(),
            groups: Vec::new(),
        }
    }
}

impl<F: AssetFetcher> SceneHostCore<F> {
    pub fn store_product_options(
        &mut self,
        options: ProductOptionsV1,
    ) -> Result<ProductOptionsV1, SceneHostError> {
        validate_product_options(&options)?;
        self.product_options = options;
        Ok(self.product_options())
    }

    pub fn store_product_options_json(&mut self, json: &str) -> Result<String, SceneHostError> {
        let options: ProductOptionsV1 = serde_json::from_str(json).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::InvalidInput,
                format!("invalid product options JSON: {error}"),
            )
        })?;
        let report = self.store_product_options(options)?;
        product_options_json(&report)
    }

    pub fn product_options(&self) -> ProductOptionsV1 {
        self.product_options.clone()
    }

    pub fn product_options_json(&self) -> Result<String, SceneHostError> {
        product_options_json(&self.product_options)
    }

    pub fn apply_product_option(
        &mut self,
        group_id: &str,
        option_id: &str,
    ) -> Result<VisualPatchResultV1, SceneHostError> {
        let patch = self.product_option_patch(group_id, option_id)?;
        let result = self.apply_patch(&patch)?;
        if result.failed.is_empty() {
            self.set_active_product_option(group_id, option_id)?;
        }
        Ok(result)
    }

    pub fn apply_product_option_json(
        &mut self,
        group_id: &str,
        option_id: &str,
    ) -> Result<String, SceneHostError> {
        let result = self.apply_product_option(group_id, option_id)?;
        serde_json::to_string(&result).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::Inspect,
                format!("product option apply result serialization failed: {error}"),
            )
        })
    }

    fn product_option_patch(
        &self,
        group_id: &str,
        option_id: &str,
    ) -> Result<VisualPatchV1, SceneHostError> {
        let group = self
            .product_options
            .groups
            .iter()
            .find(|group| group.id == group_id)
            .ok_or_else(|| invalid_input(format!("product option group '{group_id}' not found")))?;
        let option = group
            .options
            .iter()
            .find(|option| option.id == option_id)
            .ok_or_else(|| {
                invalid_input(format!(
                    "product option '{option_id}' not found in group '{group_id}'"
                ))
            })?;
        Ok(option.patch.clone())
    }

    fn set_active_product_option(
        &mut self,
        group_id: &str,
        option_id: &str,
    ) -> Result<(), SceneHostError> {
        let group = self
            .product_options
            .groups
            .iter_mut()
            .find(|group| group.id == group_id)
            .ok_or_else(|| invalid_input(format!("product option group '{group_id}' not found")))?;
        group.active = Some(option_id.to_owned());
        Ok(())
    }
}

fn validate_product_options(options: &ProductOptionsV1) -> Result<(), SceneHostError> {
    if options.schema != PRODUCT_OPTIONS_SCHEMA_V1 {
        return Err(invalid_input(format!(
            "unsupported product options schema {}; expected {}",
            options.schema, PRODUCT_OPTIONS_SCHEMA_V1
        )));
    }

    let mut group_ids = BTreeSet::new();
    for group in &options.groups {
        validate_id("product option group id", &group.id)?;
        if !group_ids.insert(group.id.clone()) {
            return Err(invalid_input(format!(
                "duplicate product option group id '{}'",
                group.id
            )));
        }

        let mut option_ids = BTreeSet::new();
        for option in &group.options {
            validate_id("product option id", &option.id)?;
            if !option_ids.insert(option.id.clone()) {
                return Err(invalid_input(format!(
                    "duplicate product option id '{}' in group '{}'",
                    option.id, group.id
                )));
            }
            if option.patch.schema != VISUAL_PATCH_SCHEMA_V1 {
                return Err(invalid_input(format!(
                    "product option '{}.{}' patch schema must be {}; got {}",
                    group.id, option.id, VISUAL_PATCH_SCHEMA_V1, option.patch.schema
                )));
            }
        }

        if let Some(active) = &group.active
            && !option_ids.contains(active)
        {
            return Err(invalid_input(format!(
                "active product option '{active}' is not present in group '{}'",
                group.id
            )));
        }
    }
    Ok(())
}

fn validate_id(label: &str, value: &str) -> Result<(), SceneHostError> {
    if value.trim().is_empty() || value.chars().any(char::is_control) {
        return Err(invalid_input(format!(
            "{label} must be non-empty and contain no control characters"
        )));
    }
    Ok(())
}

fn product_options_json(options: &ProductOptionsV1) -> Result<String, SceneHostError> {
    serde_json::to_string(options).map_err(|error| {
        SceneHostError::new(
            SceneHostErrorCode::Inspect,
            format!("product options serialization failed: {error}"),
        )
    })
}

fn invalid_input(message: String) -> SceneHostError {
    SceneHostError::new(SceneHostErrorCode::InvalidInput, message)
}