mecha10-sim 0.6.3

Mecha10 simulation support — scene generation, asset management
Documentation
//! Environment config (`environment.json`) parsing and validation.
//!
//! This module is the Rust-side representation of the `features.objects[]` array in an
//! environment's `environment.json` (schema: `packages/simulation/schemas/environment-config.schema.json`),
//! most notably the `type: "image"` billboard object introduced for LAB-2025 (porting Godot's
//! config-driven PNG billboard rendering to the MuJoCo simulator).
//!
//! Scope note (LAB-2025): the actual MJCF generation that turns a `type: "image"` object into a
//! MuJoCo `<texture>`/`<material>`/`<geom type="plane">` triple lives in
//! `mecha10_rl.mujoco.mjcf_generator` (Python), which was extracted out of this repo into the
//! external `mecha-industries/mecha10-rl` repo (LAB-1830) and is not reachable from here. This
//! module only covers what's verifiable in-repo: parsing `environment.json` into typed Rust
//! structs and validating that an image object's fields (in particular, that `source` resolves to
//! a file that actually exists on disk) are well-formed. See
//! `packages/simulation/mujoco/ASSET_SCHEMA.md` for the target MJCF mapping contract and the
//! tracked `mecha10-rl` follow-up.

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

/// Top-level `environment.json` document.
///
/// Only the fields relevant to object placement are modeled here; other top-level fields
/// (`dimensions`, `rewards`, etc.) are intentionally not represented since nothing in-repo
/// currently needs to consume them from Rust.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentConfig {
    pub id: String,
    pub version: String,
    pub name: String,
    #[serde(default)]
    pub description: String,
    pub features: EnvironmentFeatures,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvironmentFeatures {
    /// Custom objects placed in the scene (cubes, image billboards, ...).
    #[serde(default)]
    pub objects: Vec<SceneObject>,
}

/// A single entry in `features.objects[]`.
///
/// Tagged on `type`: `"cube"` maps to a MuJoCo box geom (already generated today), `"image"`
/// maps to a static oriented plane geom with an image texture (the LAB-2025 billboard feature —
/// see the module doc for the current scope boundary).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SceneObject {
    Cube(CubeObject),
    Image(ImageObject),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CubeObject {
    pub position: [f64; 3],
    #[serde(default = "default_scale")]
    pub size: [f64; 3],
    #[serde(default)]
    pub color: Option<[f64; 4]>,
    #[serde(default)]
    pub add_collision: bool,
    #[serde(default)]
    pub material_type: Option<String>,
    #[serde(default)]
    pub roughness: Option<f64>,
    #[serde(default)]
    pub metallic: Option<f64>,
}

/// A config-driven PNG/JPG "billboard" image object (LAB-2025).
///
/// Target MuJoCo mapping (see `packages/simulation/mujoco/ASSET_SCHEMA.md`): a `<texture
/// type="2d">` loaded from `source`, a `<material>` referencing that texture, and a `<geom
/// type="plane">` at `position`/`rotation` sized by `scale`. MuJoCo has no true camera-facing
/// billboard primitive, so a statically oriented plane is the intended semantics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageObject {
    /// Path to the image asset, relative to the repo/project root (e.g.
    /// `"packages/simulation/environments/basic_arena/assets/images/aiko.jpg"`).
    pub source: String,
    pub position: [f64; 3],
    /// Rotation in degrees, `[x, y, z]`.
    #[serde(default)]
    pub rotation: [f64; 3],
    /// Uniform or per-axis scale. Stored as `[x, y, z]` in JSON; a bare scalar in
    /// `environment.json` is not currently supported by this parser (all existing configs use
    /// the array form).
    #[serde(default = "default_scale")]
    pub scale: [f64; 3],
    /// Whether the object responds to click/interaction (unused by the MuJoCo backend today;
    /// carried over from the Godot-era schema for forward compatibility).
    #[serde(default)]
    pub interactive: bool,
    #[serde(default = "default_true")]
    pub visible: bool,
}

fn default_scale() -> [f64; 3] {
    [1.0, 1.0, 1.0]
}

fn default_true() -> bool {
    true
}

impl EnvironmentConfig {
    /// Load and parse an `environment.json` file from `path`.
    pub fn load(path: &Path) -> Result<Self> {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read environment config: {}", path.display()))?;
        let config: EnvironmentConfig = serde_json::from_str(&content)
            .with_context(|| format!("Failed to parse environment config: {}", path.display()))?;
        Ok(config)
    }

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

impl ImageObject {
    /// Validate this image object, resolving `source` relative to `repo_root` and confirming the
    /// asset file actually exists on disk.
    ///
    /// This is the extent of "generates valid MuJoCo XML" verification achievable from this repo
    /// (LAB-2025): we cannot invoke the external `mecha10_rl.mujoco.mjcf_generator` from here, so
    /// this validates the config contract that generator depends on instead.
    pub fn validate(&self, repo_root: &Path) -> Result<PathBuf> {
        if self.source.trim().is_empty() {
            bail!("image object has an empty `source` path");
        }

        let resolved = repo_root.join(&self.source);
        if !resolved.is_file() {
            bail!(
                "image object `source` does not resolve to a file: {} (resolved: {})",
                self.source,
                resolved.display()
            );
        }

        if self.scale.iter().any(|component| *component <= 0.0) {
            bail!("image object `scale` components must be positive, got {:?}", self.scale);
        }

        Ok(resolved)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn repo_root() -> PathBuf {
        // packages/mecha10-sim -> repo root is two levels up.
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(Path::parent)
            .expect("repo root")
            .to_path_buf()
    }

    #[test]
    fn parses_cube_object() {
        let json = r#"{
            "type": "cube",
            "position": [1.0, 0.5, 2.0],
            "size": [1.0, 1.0, 1.0],
            "color": [0.8, 0.2, 0.2, 1.0],
            "add_collision": true,
            "material_type": "standard",
            "roughness": 0.8,
            "metallic": 0.0
        }"#;
        let obj: SceneObject = serde_json::from_str(json).unwrap();
        match obj {
            SceneObject::Cube(cube) => {
                assert_eq!(cube.position, [1.0, 0.5, 2.0]);
                assert!(cube.add_collision);
            }
            SceneObject::Image(_) => panic!("expected cube"),
        }
    }

    #[test]
    fn parses_image_object() {
        let json = r#"{
            "type": "image",
            "source": "packages/simulation/environments/basic_arena/assets/images/aiko.jpg",
            "position": [0.0, 0.5, 3.5],
            "rotation": [0, 180, 0],
            "scale": [2.5, 2.5, 2.5],
            "interactive": false,
            "visible": true
        }"#;
        let obj: SceneObject = serde_json::from_str(json).unwrap();
        match obj {
            SceneObject::Image(image) => {
                assert_eq!(
                    image.source,
                    "packages/simulation/environments/basic_arena/assets/images/aiko.jpg"
                );
                assert_eq!(image.position, [0.0, 0.5, 3.5]);
                assert_eq!(image.rotation, [0.0, 180.0, 0.0]);
                assert_eq!(image.scale, [2.5, 2.5, 2.5]);
                assert!(!image.interactive);
                assert!(image.visible);
            }
            SceneObject::Cube(_) => panic!("expected image"),
        }
    }

    #[test]
    fn image_object_defaults() {
        let json = r#"{
            "type": "image",
            "source": "assets/waypoint.png",
            "position": [1.0, 1.0, 1.0]
        }"#;
        let obj: SceneObject = serde_json::from_str(json).unwrap();
        match obj {
            SceneObject::Image(image) => {
                assert_eq!(image.rotation, [0.0, 0.0, 0.0]);
                assert_eq!(image.scale, [1.0, 1.0, 1.0]);
                assert!(!image.interactive);
                assert!(image.visible);
            }
            SceneObject::Cube(_) => panic!("expected image"),
        }
    }

    #[test]
    fn loads_basic_arena_environment_and_finds_aiko() {
        let root = repo_root();
        let path = root.join("packages/simulation/environments/basic_arena/environment.json");
        let config = EnvironmentConfig::load(&path).expect("basic_arena environment.json should parse");

        assert_eq!(config.id, "basic_arena");

        // cat_tower.jpg was a 37-byte placeholder text file, never a real image; it was removed
        // (rather than kept/faked) once mecha10-rl's MJCF texture generator started actually
        // decoding source images and hard-failed on it at environment-load time.
        assert!(
            config
                .image_objects()
                .all(|image| !image.source.ends_with("cat_tower.jpg")),
            "basic_arena should no longer declare a cat_tower.jpg image object"
        );

        let aiko = config
            .image_objects()
            .find(|image| image.source.ends_with("aiko.jpg"))
            .expect("basic_arena should declare an aiko.jpg image object");

        let resolved = aiko.validate(&root).expect("aiko image object should validate");
        assert!(resolved.is_file());
        assert_eq!(aiko.position.len(), 3);
        assert_eq!(aiko.rotation.len(), 3);
        assert_eq!(aiko.scale.len(), 3);
    }

    #[test]
    fn validate_rejects_missing_source_file() {
        let root = repo_root();
        let image = ImageObject {
            source: "packages/simulation/environments/basic_arena/assets/does_not_exist.jpg".to_string(),
            position: [0.0, 0.0, 0.0],
            rotation: [0.0, 0.0, 0.0],
            scale: [1.0, 1.0, 1.0],
            interactive: false,
            visible: true,
        };
        let err = image
            .validate(&root)
            .expect_err("missing source file should fail validation");
        assert!(err.to_string().contains("does not resolve to a file"));
    }

    #[test]
    fn validate_rejects_zero_scale() {
        let root = repo_root();
        let image = ImageObject {
            source: "packages/simulation/environments/basic_arena/assets/images/aiko.jpg".to_string(),
            position: [0.0, 0.0, 0.0],
            rotation: [0.0, 0.0, 0.0],
            scale: [0.0, 1.0, 1.0],
            interactive: false,
            visible: true,
        };
        let err = image.validate(&root).expect_err("zero scale should fail validation");
        assert!(err.to_string().contains("scale"));
    }
}