mecha10-sim 0.8.2

Mecha10 simulation support — scene generation, asset management
Documentation
//! Preset config (`preset.json`) parsing and validation.
//!
//! LAB-3629: a task no longer has to author its own scene content inline (`task.json`'s old
//! `objects[]` key) -- it instead names a `preset` (`task.json`'s `preset` key), resolved
//! against the task's own active environment. A preset is a named, environment-namespaced
//! content set of `objects[]` -- the same shape as `environment.json`'s `features.objects[]`,
//! reusing [`environment_config`]'s `SceneObject`/`CubeObject`/`ImageObject` types -- that gets
//! merged onto the environment at runtime by the consuming side (the external `mecha10-rl`
//! repo's `AssetResolver.load_preset` + `Mecha10Gym._merge_preset_objects`, not reachable from
//! this repo -- see `environment_config.rs`'s module docs for the same Rust/Python split, and
//! the "Simulation: MuJoCo Architecture" Linear doc for the full contract).
//!
//! This repo's job is limited to parsing/validating `preset.json` files, which live at
//! `configs/simulation/environments/<env_name>/presets/<preset_name>/preset.json` --
//! namespaced *per-environment* (not a flat top-level kind like models/environments/tasks),
//! since a preset's objects only make sense relative to the arena they were authored for. Two
//! different environments may each have their own, unrelated, same-named preset (e.g. both
//! `basic_arena` and some other environment could each define their own `cats` preset) without
//! colliding.
//!
//! Schema: `configs/simulation/schemas/preset-config.schema.json`, published by `mecha10-assets`
//! and fetched at runtime, modeled on `environment-config.schema.json`. Unlike environment configs
//! (`config_validation.rs`'s `validate_environment_configs`), nothing in this repo wires that
//! schema into `mecha10 config validate`/`push` yet -- that's tracked separately (LAB-3632).

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

use super::environment_config::{ImageObject, SceneObject};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresetConfig {
    pub id: String,
    pub version: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub objects: Vec<SceneObject>,
}

impl PresetConfig {
    /// Load and parse a `preset.json` file from `path`.
    pub fn load(path: &Path) -> Result<Self> {
        super::load_json(path, "preset config")
    }

    /// All `type: "image"` billboard objects declared in this preset.
    pub fn image_objects(&self) -> impl Iterator<Item = &ImageObject> {
        self.objects.iter().filter_map(|obj| match obj {
            SceneObject::Image(image) => Some(image),
            SceneObject::Cube(_) => None,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sim::environment_config::SceneObject;

    // The real `preset.json` bundle used to live in-repo under
    // `mecha10-templates/templates/configs/simulation/environments/basic_arena/presets/`. That
    // tree was extracted to the separate `mecha10-assets` repo and deleted from here
    // (mecha-industries/mecha10#1348) - it's now served live via `mecha10-api`'s
    // `GET /api/downloads/assets`, not readable from this crate at test time. The constants below
    // are realistic hand-maintained copies of what `mecha10-assets` publishes (kept in lockstep
    // by hand - there is no compile-time or test-time link to the real files anymore).

    const CATS_PRESET_JSON: &str = r#"{
        "id": "cats",
        "version": "1.0.0",
        "description": "Cat detection targets (aiko/phoebe) for find_target-style tasks - two stationary image billboards positioned for a rotate-in-place-to-center find/hold task.",
        "objects": [
            {
                "type": "image",
                "source": "configs/simulation/images/aiko.jpg",
                "position": [-3.0, 0.5, 0.0],
                "rotation": [0, 90, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true
            },
            {
                "type": "image",
                "source": "configs/simulation/images/phoebe.jpg",
                "position": [3.0, 0.5, 1.5],
                "rotation": [0, -90, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true
            }
        ]
    }"#;

    const DEFAULT_PRESET_JSON: &str = r#"{
        "id": "default",
        "version": "1.0.0",
        "description": "Generic/neutral image set (not cat detection targets) - used by the `default` task.",
        "objects": [
            {
                "type": "image",
                "source": "configs/simulation/images/dog1.jpg",
                "position": [0.0, 0.5, 3.0],
                "rotation": [0, 180, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true
            },
            {
                "type": "image",
                "source": "configs/simulation/images/car1.jpg",
                "position": [0.0, 0.5, -3.0],
                "rotation": [0, 0, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true
            },
            {
                "type": "image",
                "source": "configs/simulation/images/bicycle1.jpg",
                "position": [4.243, 0.5, 4.243],
                "rotation": [0, 135, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true
            }
        ]
    }"#;

    const OBJECTS_PATROL_PRESET_JSON: &str = r#"{
        "id": "objects_patrol",
        "version": "1.0.0",
        "description": "Same mixed image set as the `objects` preset, but every object declares a patrol `motion` block instead of sitting still.",
        "objects": [
            {
                "type": "image",
                "source": "configs/simulation/images/aiko.jpg",
                "position": [-3.0, 0.5, 0.0],
                "rotation": [0, 90, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true,
                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
            },
            {
                "type": "image",
                "source": "configs/simulation/images/phoebe.jpg",
                "position": [3.0, 0.5, 1.5],
                "rotation": [0, -116.6, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true,
                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
            },
            {
                "type": "image",
                "source": "configs/simulation/images/dog1.jpg",
                "position": [0.0, 0.5, 3.0],
                "rotation": [0, 180, 0],
                "scale": [1.0, 1.0, 1.0],
                "interactive": false,
                "visible": true,
                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
            }
        ]
    }"#;

    #[test]
    fn parses_a_minimal_preset() {
        let json = r#"{
            "id": "cats",
            "version": "1.0.0",
            "description": "Cat detection targets",
            "objects": []
        }"#;
        let config: PresetConfig = serde_json::from_str(json).unwrap();
        assert_eq!(config.id, "cats");
        assert!(config.objects.is_empty());
    }

    #[test]
    fn parses_a_preset_with_image_objects() {
        let json = r#"{
            "id": "cats",
            "version": "1.0.0",
            "objects": [
                {
                    "type": "image",
                    "source": "configs/simulation/images/aiko.jpg",
                    "position": [-3.0, 0.5, 0.0],
                    "rotation": [0, 90, 0]
                }
            ]
        }"#;
        let config: PresetConfig = serde_json::from_str(json).unwrap();
        let images: Vec<&ImageObject> = config.image_objects().collect();
        assert_eq!(images.len(), 1);
        assert_eq!(images[0].source, "configs/simulation/images/aiko.jpg");
    }

    #[test]
    fn rejects_malformed_object_entry() {
        let json = r#"{
            "id": "broken",
            "version": "1.0.0",
            "objects": [{"type": "image"}]
        }"#;
        let err = serde_json::from_str::<PresetConfig>(json).unwrap_err();
        assert!(err.to_string().contains("source") || err.to_string().contains("position"));
    }

    #[test]
    fn loads_the_bundled_cats_preset() {
        let config: PresetConfig =
            serde_json::from_str(CATS_PRESET_JSON).expect("basic_arena's cats preset.json should parse");

        assert_eq!(config.id, "cats");
        let images: Vec<&ImageObject> = config.image_objects().collect();
        assert!(
            images.iter().any(|i| i.source.ends_with("aiko.jpg")),
            "cats preset should include aiko.jpg"
        );
        assert!(
            images.iter().any(|i| i.source.ends_with("phoebe.jpg")),
            "cats preset should include phoebe.jpg"
        );
    }

    #[test]
    fn loads_the_bundled_default_preset() {
        let config: PresetConfig =
            serde_json::from_str(DEFAULT_PRESET_JSON).expect("basic_arena's default preset.json should parse");

        assert_eq!(config.id, "default");
        assert!(
            !config.objects.is_empty(),
            "default preset should carry the generic/neutral image set"
        );
        assert!(
            config
                .image_objects()
                .all(|i| !i.source.ends_with("aiko.jpg") && !i.source.ends_with("phoebe.jpg")),
            "default preset should be the generic/neutral set, not the cat detection targets"
        );
    }

    #[test]
    fn loads_the_bundled_objects_patrol_preset_with_motion() {
        let config: PresetConfig = serde_json::from_str(OBJECTS_PATROL_PRESET_JSON)
            .expect("basic_arena's objects_patrol preset.json should parse");

        assert!(!config.objects.is_empty());
        for obj in &config.objects {
            match obj {
                SceneObject::Image(image) => {
                    assert!(
                        image.motion.is_some(),
                        "every object in objects_patrol should declare a motion block, {} did not",
                        image.source
                    );
                }
                SceneObject::Cube(_) => panic!("objects_patrol should only contain image objects"),
            }
        }
    }
}