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,
#[serde(default)]
pub description: String,
pub features: EnvironmentFeatures,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvironmentFeatures {
#[serde(default)]
pub objects: Vec<SceneObject>,
}
#[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,
}
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": "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() {
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() {
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"));
}
}