mecha10-sim 0.6.5

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, Result};
use serde::{Deserialize, Serialize};
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/images/aiko.jpg"`, `"configs/simulation/images/aiko.jpg"` in a
    /// scaffolded project).
    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> {
        super::load_json(path, "environment 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 {
        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/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/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 basic_arena_environment_stays_generic_with_no_task_specific_image_objects() {
        // basic_arena is a task-agnostic arena (LAB-2430 follow-up) - detection-target images
        // like aiko.jpg/phoebe.jpg belong to the `find_target` task that spawns them, not to
        // every environment that happens to host it. See `find_target_task_declares_its_own_image_objects`.
        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");

        assert!(
            config.image_objects().next().is_none(),
            "basic_arena should declare no image objects - those are task-specific, got: {:?}",
            config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
        );
    }

    #[test]
    fn find_target_task_declares_its_own_image_objects() {
        // `task.json`'s `objects` field uses the exact same shape as `environment.json`'s
        // `features.objects[]` (see `mecha10_rl.mujoco.goal_conditioned_env._merge_task_objects`,
        // which merges a task's objects onto whichever arena hosts it) - reuse `SceneObject`/
        // `ImageObject` directly rather than a separate Rust task-config type.
        let root = repo_root();
        let path = root.join("packages/simulation/tasks/find_target/task.json");
        let raw = std::fs::read_to_string(&path).expect("find_target/task.json should read");
        let value: serde_json::Value = serde_json::from_str(&raw).expect("find_target/task.json should parse");
        let objects: Vec<SceneObject> =
            serde_json::from_value(value["objects"].clone()).expect("task.json objects[] should parse");

        let images: Vec<&ImageObject> = objects
            .iter()
            .filter_map(|obj| match obj {
                SceneObject::Image(image) => Some(image),
                SceneObject::Cube(_) => None,
            })
            .collect();

        let aiko = images
            .iter()
            .find(|image| image.source.ends_with("aiko.jpg"))
            .expect("find_target task 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);

        assert!(
            images.iter().any(|image| image.source.ends_with("phoebe.jpg")),
            "find_target task should also declare a phoebe.jpg image object"
        );
    }

    #[test]
    fn validate_rejects_missing_source_file() {
        let root = repo_root();
        let image = ImageObject {
            source: "packages/simulation/images/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/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"));
    }
}