use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentConfig {
pub id: String,
pub version: String,
pub name: String,
pub description: String,
pub scene_path: String,
pub dimensions: EnvironmentDimensions,
pub features: EnvironmentFeatures,
#[serde(default)]
pub rewards: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentDimensions {
pub width: f64,
pub length: f64,
pub height: f64,
pub unit: DistanceUnit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DistanceUnit {
Meters,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvironmentFeatures {
#[serde(default)]
pub floor: Option<FloorFeature>,
#[serde(default)]
pub walls: Option<WallsFeature>,
#[serde(default)]
pub lighting: Option<LightingFeature>,
#[serde(default)]
pub spawn_points: Option<SpawnPointsFeature>,
#[serde(default)]
pub obstacles: Option<ObstaclesFeature>,
#[serde(default)]
pub objects: Vec<SceneObject>,
#[serde(default)]
pub goals: Option<GoalsFeature>,
#[serde(default)]
pub npcs: Option<NpcsFeature>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FloorFeature {
#[serde(rename = "type")]
pub floor_type: String,
pub material: String,
pub friction: f64,
pub collision: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WallsFeature {
pub count: u32,
pub height: f64,
pub thickness: f64,
pub material: String,
pub collision: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightingFeature {
pub directional: DirectionalLight,
pub ambient: AmbientLight,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectionalLight {
pub enabled: bool,
pub energy: f64,
pub shadows: bool,
pub angle: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmbientLight {
pub enabled: bool,
pub color: [f64; 4],
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnPointsFeature {
pub count: u32,
pub positions: Vec<[f64; 3]>,
pub default_index: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObstaclesFeature {
pub count: u32,
#[serde(rename = "type")]
pub obstacle_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalsFeature {
pub count: u32,
#[serde(rename = "type")]
pub goal_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcsFeature {
pub enabled: bool,
pub count: u32,
}
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageObject {
pub source: String,
pub position: [f64; 3],
#[serde(default)]
pub rotation: [f64; 3],
#[serde(default = "default_scale")]
pub scale: [f64; 3],
#[serde(default)]
pub interactive: bool,
#[serde(default = "default_true")]
pub visible: bool,
#[serde(default)]
pub motion: Option<ObjectMotion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectMotion {
#[serde(rename = "type")]
pub motion_type: MotionType,
pub axis: MotionAxis,
pub range_min: f64,
pub range_max: f64,
#[serde(default = "default_motion_speed")]
pub speed: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MotionType {
Patrol,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MotionAxis {
X,
Y,
}
fn default_motion_speed() -> f64 {
1.0
}
fn default_scale() -> [f64; 3] {
[1.0, 1.0, 1.0]
}
fn default_true() -> bool {
true
}
impl EnvironmentConfig {
pub fn load(path: &Path) -> Result<Self> {
super::load_json(path, "environment config")
}
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 {
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": "configs/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, "configs/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() {
let root = repo_root();
let path = root
.join("packages/mecha10-templates/templates/configs/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_references_its_cats_preset() {
let root = repo_root();
let path = root.join("packages/mecha10-templates/templates/configs/rl/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");
assert_eq!(
value["preset"], "cats",
"find_target should reference its 'cats' preset (LAB-3629) rather than authoring objects[] inline"
);
assert!(
value.get("objects").is_none(),
"find_target/task.json should no longer declare an inline objects[] key"
);
}
#[test]
fn default_task_references_its_default_preset() {
let root = repo_root();
let path = root.join("packages/mecha10-templates/templates/configs/rl/tasks/default/task.json");
let raw = std::fs::read_to_string(&path).expect("default/task.json should read");
let value: serde_json::Value = serde_json::from_str(&raw).expect("default/task.json should parse");
assert_eq!(
value["env_name"], "basic_arena",
"default task must declare which environment its 'default' preset resolves against"
);
assert_eq!(
value["preset"], "default",
"default task should reference its 'default' preset (LAB-3630) rather than authoring objects[] inline"
);
assert!(
value.get("objects").is_none(),
"default/task.json should not declare an inline objects[] key"
);
}
#[test]
fn find_target_cats_preset_declares_its_own_image_objects() {
let root = repo_root();
let path = root.join(
"packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/presets/cats/preset.json",
);
let raw = std::fs::read_to_string(&path).expect("basic_arena's cats preset.json should read");
let value: serde_json::Value = serde_json::from_str(&raw).expect("preset.json should parse");
let objects: Vec<SceneObject> =
serde_json::from_value(value["objects"].clone()).expect("preset.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("cats preset 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")),
"cats preset should also declare a phoebe.jpg image object"
);
}
#[test]
fn parses_image_object_with_motion_block() {
let json = r#"{
"type": "image",
"source": "configs/simulation/images/aiko.jpg",
"position": [-3.0, 0.5, 0.0],
"motion": {
"type": "patrol",
"axis": "x",
"range_min": -8.0,
"range_max": 8.0,
"speed": 1.0
}
}"#;
let obj: SceneObject = serde_json::from_str(json).unwrap();
match obj {
SceneObject::Image(image) => {
let motion = image.motion.expect("motion block should round-trip");
assert_eq!(motion.motion_type, MotionType::Patrol);
assert_eq!(motion.axis, MotionAxis::X);
assert_eq!(motion.range_min, -8.0);
assert_eq!(motion.range_max, 8.0);
assert_eq!(motion.speed, 1.0);
}
SceneObject::Cube(_) => panic!("expected image"),
}
}
#[test]
fn image_object_motion_defaults_to_none_and_speed_defaults_to_one() {
let no_motion: SceneObject =
serde_json::from_str(r#"{"type": "image", "source": "assets/waypoint.png", "position": [1.0, 1.0, 1.0]}"#)
.unwrap();
match no_motion {
SceneObject::Image(image) => assert!(image.motion.is_none()),
SceneObject::Cube(_) => panic!("expected image"),
}
let motion_without_speed: SceneObject = serde_json::from_str(
r#"{
"type": "image",
"source": "assets/waypoint.png",
"position": [1.0, 1.0, 1.0],
"motion": {"type": "patrol", "axis": "y", "range_min": -2.0, "range_max": 2.0}
}"#,
)
.unwrap();
match motion_without_speed {
SceneObject::Image(image) => {
let motion = image.motion.expect("motion block should round-trip");
assert_eq!(motion.axis, MotionAxis::Y);
assert_eq!(motion.speed, 1.0, "speed should default to 1.0 when omitted");
}
SceneObject::Cube(_) => panic!("expected image"),
}
}
#[test]
fn find_target_patrol_task_references_its_objects_patrol_preset() {
let root = repo_root();
let path = root.join("packages/mecha10-templates/templates/configs/rl/tasks/find_target_patrol/task.json");
let raw = std::fs::read_to_string(&path).expect("find_target_patrol/task.json should read");
let value: serde_json::Value = serde_json::from_str(&raw).expect("find_target_patrol/task.json should parse");
assert_eq!(
value["env_name"], "basic_arena",
"find_target_patrol must reuse basic_arena"
);
assert_eq!(
value["preset"], "objects_patrol",
"find_target_patrol should reference its 'objects_patrol' preset (LAB-3629) rather than authoring objects[] inline"
);
assert!(
value.get("objects").is_none(),
"find_target_patrol/task.json should no longer declare an inline objects[] key"
);
}
#[test]
fn objects_patrol_preset_declares_patrol_motion_on_its_image_objects() {
let root = repo_root();
let path = root.join(
"packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/presets/objects_patrol/preset.json",
);
let raw = std::fs::read_to_string(&path).expect("basic_arena's objects_patrol preset.json should read");
let value: serde_json::Value = serde_json::from_str(&raw).expect("preset.json should parse");
let objects: Vec<SceneObject> =
serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
let images: Vec<&ImageObject> = objects
.iter()
.filter_map(|obj| match obj {
SceneObject::Image(image) => Some(image),
SceneObject::Cube(_) => None,
})
.collect();
assert!(!images.is_empty(), "find_target_patrol should declare image objects");
let templates_root = root.join("packages/mecha10-templates/templates");
for image in &images {
let resolved = image
.validate(&templates_root)
.expect("patrol image object should validate");
assert!(resolved.is_file());
let motion = image
.motion
.as_ref()
.unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
assert_eq!(motion.motion_type, MotionType::Patrol);
assert_eq!(motion.axis, MotionAxis::X);
assert!(
motion.range_min < motion.range_max,
"range_min should be less than range_max for {}",
image.source
);
assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
}
}
#[test]
fn moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles() {
let root = repo_root();
let path = root.join(
"packages/mecha10-templates/templates/configs/simulation/environments/moving_objects_arena/environment.json",
);
let config = EnvironmentConfig::load(&path).expect("moving_objects_arena environment.json should parse");
assert_eq!(config.id, "moving_objects_arena");
let images: Vec<&ImageObject> = config.image_objects().collect();
assert!(
images.len() >= 2,
"moving_objects_arena should declare at least two moving image objects, got {}",
images.len()
);
for image in &images {
let resolved = image
.validate(&root)
.unwrap_or_else(|e| panic!("{} should validate: {e}", image.source));
assert!(resolved.is_file());
let motion = image
.motion
.as_ref()
.unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
assert_eq!(motion.motion_type, MotionType::Patrol);
assert!(
motion.range_min < motion.range_max,
"range_min should be less than range_max for {}",
image.source
);
assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
}
assert!(
config
.features
.obstacles
.as_ref()
.map_or(true, |obstacles| obstacles.count == 0),
"moving_objects_arena should have no static obstacles cluttering the moving-target test"
);
}
#[test]
fn validate_rejects_missing_source_file() {
let root = repo_root();
let image = ImageObject {
source: "configs/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,
motion: None,
};
let err = image
.validate(&root)
.expect_err("missing source file should fail validation");
assert!(err.to_string().contains("does not resolve to a file"));
}
fn minimal_environment_json() -> serde_json::Value {
serde_json::json!({
"id": "basic_arena",
"version": "1.0.0",
"name": "Basic Arena",
"description": "A test arena",
"scene_path": "res://basic_arena.tscn",
"dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
"features": {}
})
}
#[test]
fn parses_a_minimal_valid_environment_config() {
let config: EnvironmentConfig = serde_json::from_value(minimal_environment_json()).unwrap();
assert_eq!(config.scene_path, "res://basic_arena.tscn");
assert_eq!(config.dimensions.width, 20.0);
assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
assert!(config.features.floor.is_none());
}
#[test]
fn rejects_environment_config_missing_scene_path() {
let mut value = minimal_environment_json();
value.as_object_mut().unwrap().remove("scene_path");
let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
assert!(err.to_string().contains("scene_path"), "{err}");
}
#[test]
fn rejects_environment_config_missing_dimensions() {
let mut value = minimal_environment_json();
value.as_object_mut().unwrap().remove("dimensions");
let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
assert!(err.to_string().contains("dimensions"), "{err}");
}
#[test]
fn rejects_malformed_floor_feature() {
let mut value = minimal_environment_json();
value["features"]["floor"] = serde_json::json!({
"type": "flat",
"material": "concrete",
"friction": 0.8
});
let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
assert!(err.to_string().contains("collision"), "{err}");
}
#[test]
fn parses_full_basic_arena_environment_including_dimensions_and_features() {
let root = repo_root();
let path = root
.join("packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/environment.json");
let config = EnvironmentConfig::load(&path).expect("basic_arena environment.json should parse");
assert_eq!(
config.scene_path,
"res://packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/basic_arena.tscn"
);
assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
let floor = config.features.floor.expect("basic_arena should declare a floor");
assert_eq!(floor.material, "concrete");
assert!(floor.collision);
let walls = config.features.walls.expect("basic_arena should declare walls");
assert_eq!(walls.count, 4);
let lighting = config.features.lighting.expect("basic_arena should declare lighting");
assert!(lighting.directional.enabled);
assert!(lighting.ambient.enabled);
let spawn_points = config
.features
.spawn_points
.expect("basic_arena should declare spawn points");
assert_eq!(spawn_points.count, 5);
assert_eq!(spawn_points.positions.len(), 5);
let obstacles = config.features.obstacles.expect("basic_arena should declare obstacles");
assert_eq!(obstacles.obstacle_type, "mixed");
let goals = config.features.goals.expect("basic_arena should declare goals");
assert_eq!(goals.count, 0);
let npcs = config.features.npcs.expect("basic_arena should declare npcs");
assert!(!npcs.enabled);
}
#[test]
fn home_outdoor_urban_environment_stays_generic_with_no_task_specific_image_objects() {
let root = repo_root();
let path = root.join(
"packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/environment.json",
);
let config = EnvironmentConfig::load(&path).expect("home_outdoor_urban environment.json should parse");
assert_eq!(config.id, "home_outdoor_urban");
assert!(
config.image_objects().next().is_none(),
"home_outdoor_urban should declare no image objects - those are task-specific, got: {:?}",
config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
);
}
#[test]
fn every_bundled_environment_is_structural_only_except_the_tracked_moving_objects_arena_exception() {
const TRACKED_EXCEPTIONS: &[&str] = &["moving_objects_arena"];
let root = repo_root();
let environments_dir = root.join("packages/mecha10-templates/templates/configs/simulation/environments");
let mut checked = Vec::new();
for entry in std::fs::read_dir(&environments_dir).expect("environments dir should be readable") {
let entry = entry.expect("dir entry should be readable");
if !entry.file_type().expect("file type should be readable").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if TRACKED_EXCEPTIONS.contains(&name.as_str()) {
continue;
}
let path = entry.path().join("environment.json");
let config =
EnvironmentConfig::load(&path).unwrap_or_else(|e| panic!("{} should parse: {e}", path.display()));
assert!(
config.image_objects().next().is_none(),
"{} should declare no image objects - environments are structural-only, task \
content belongs in task.json, got: {:?}",
name,
config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
);
checked.push(name);
}
assert!(
checked.contains(&"basic_arena".to_string()) && checked.contains(&"home_outdoor_urban".to_string()),
"expected to have actually checked the known structural-only environments, got: {checked:?}"
);
}
#[test]
fn parses_full_home_outdoor_urban_environment_including_dimensions_and_features() {
let root = repo_root();
let path = root.join(
"packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/environment.json",
);
let config = EnvironmentConfig::load(&path).expect("home_outdoor_urban environment.json should parse");
assert_eq!(
config.scene_path,
"res://packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/home_outdoor_urban.tscn"
);
assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
assert!(config.dimensions.width > 20.0);
assert!(config.dimensions.length > 0.0);
let floor = config
.features
.floor
.expect("home_outdoor_urban should declare a floor");
assert!(floor.collision);
let walls = config.features.walls.expect("home_outdoor_urban should declare walls");
assert_eq!(walls.count, 4);
let lighting = config
.features
.lighting
.expect("home_outdoor_urban should declare lighting");
assert!(lighting.directional.enabled);
assert!(lighting.ambient.enabled);
let spawn_points = config
.features
.spawn_points
.expect("home_outdoor_urban should declare spawn points");
assert_eq!(spawn_points.count, spawn_points.positions.len() as u32);
assert!(
spawn_points.count >= 2,
"home_outdoor_urban should offer spawn points in both the home and outdoor sections"
);
let goals = config.features.goals.expect("home_outdoor_urban should declare goals");
assert!(
goals.count > 0,
"a navigation-focused environment should declare goal zones"
);
let npcs = config.features.npcs.expect("home_outdoor_urban should declare npcs");
assert!(npcs.enabled, "home_outdoor_urban should model dynamic pedestrians");
assert!(
config.features.objects.len() >= 10,
"home_outdoor_urban should be densely furnished for navigation testing, got {} objects",
config.features.objects.len()
);
assert!(
config
.features
.objects
.iter()
.all(|obj| matches!(obj, SceneObject::Cube(_))),
"home_outdoor_urban's structural/furniture objects should all be cubes, not task-specific images"
);
}
#[test]
fn validate_rejects_zero_scale() {
let root = repo_root();
let image = ImageObject {
source: "configs/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,
motion: None,
};
let err = image.validate(&root).expect_err("zero scale should fail validation");
assert!(err.to_string().contains("scale"));
}
}