Skip to main content

mecha10_sim/sim/
environment_config.rs

1//! Environment config (`environment.json`) parsing and validation.
2//!
3//! This module is the Rust-side representation of the full `environment.json` document (schema:
4//! `configs/simulation/schemas/environment-config.schema.json`, published by the separate
5//! `mecha10-assets` repo and fetched at runtime -- see
6//! `mecha10_schema_registry::environment_config_schema`), most notably the `type: "image"`
7//! billboard object introduced for LAB-2025 (porting Godot's config-driven PNG billboard
8//! rendering to the MuJoCo simulator).
9//!
10//! `EnvironmentConfig` now mirrors every field the hand-written schema requires/describes
11//! (previously only `id`/`version`/`name`/`description`/`features.objects[]` were modeled here;
12//! `scene_path`, `dimensions`, and the rest of `features` were silently unparsed even though the
13//! schema already required/described them). This is deliberately option (b) from that
14//! tightening pass rather than making this struct `schemars`-derive the schema: the hand-written
15//! schema fetched from `mecha10-assets` stays the source of truth (it's what `mecha10 config
16//! push`'s validation gate and `cargo run --example export_schemas` both read via
17//! `mecha10_schema_registry::environment_config_schema`), and this struct is kept in lockstep
18//! with it by hand -- see `config_validation.rs`'s `validate_environment_configs_rejects_*` tests
19//! (schema-side) and this module's own tests (struct-side) for the two halves of that guarantee.
20//! `rewards` stays `serde_json::Value`: no committed `environment.json` (here or in
21//! `mecha10-assets`) has ever populated it, so there's no real shape to type against yet.
22//!
23//! Scope note (LAB-2025): the actual MJCF generation that turns a `type: "image"` object into a
24//! MuJoCo `<texture>`/`<material>`/`<geom type="plane">` triple lives in
25//! `mecha10_rl.mujoco.mjcf_generator` (Python), which was extracted out of this repo into the
26//! external `mecha-industries/mecha10-rl` repo (LAB-1830) and is not reachable from here. This
27//! module only covers what's verifiable in-repo: parsing `environment.json` into typed Rust
28//! structs and validating that an image object's fields (in particular, that `source` resolves to
29//! a file that actually exists on disk) are well-formed. See
30//! the "Simulation: MuJoCo Architecture" Linear doc for the target MJCF mapping contract and the
31//! tracked `mecha10-rl` follow-up.
32
33use anyhow::{bail, Result};
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct EnvironmentConfig {
39    pub id: String,
40    pub version: String,
41    pub name: String,
42    pub description: String,
43    pub scene_path: String,
44    pub dimensions: EnvironmentDimensions,
45    pub features: EnvironmentFeatures,
46    #[serde(default)]
47    pub rewards: Option<serde_json::Value>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct EnvironmentDimensions {
52    pub width: f64,
53    pub length: f64,
54    pub height: f64,
55    pub unit: DistanceUnit,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum DistanceUnit {
61    Meters,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct EnvironmentFeatures {
66    #[serde(default)]
67    pub floor: Option<FloorFeature>,
68    #[serde(default)]
69    pub walls: Option<WallsFeature>,
70    #[serde(default)]
71    pub lighting: Option<LightingFeature>,
72    #[serde(default)]
73    pub spawn_points: Option<SpawnPointsFeature>,
74    #[serde(default)]
75    pub obstacles: Option<ObstaclesFeature>,
76    #[serde(default)]
77    pub objects: Vec<SceneObject>,
78    #[serde(default)]
79    pub goals: Option<GoalsFeature>,
80    #[serde(default)]
81    pub npcs: Option<NpcsFeature>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct FloorFeature {
86    #[serde(rename = "type")]
87    pub floor_type: String,
88    pub material: String,
89    pub friction: f64,
90    pub collision: bool,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WallsFeature {
95    pub count: u32,
96    pub height: f64,
97    pub thickness: f64,
98    pub material: String,
99    pub collision: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct LightingFeature {
104    pub directional: DirectionalLight,
105    pub ambient: AmbientLight,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct DirectionalLight {
110    pub enabled: bool,
111    pub energy: f64,
112    pub shadows: bool,
113    pub angle: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct AmbientLight {
118    pub enabled: bool,
119    pub color: [f64; 4],
120    pub source: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SpawnPointsFeature {
125    pub count: u32,
126    pub positions: Vec<[f64; 3]>,
127    pub default_index: u32,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ObstaclesFeature {
132    pub count: u32,
133    #[serde(rename = "type")]
134    pub obstacle_type: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GoalsFeature {
139    pub count: u32,
140    #[serde(rename = "type")]
141    pub goal_type: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct NpcsFeature {
146    pub enabled: bool,
147    pub count: u32,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "type", rename_all = "snake_case")]
152pub enum SceneObject {
153    Cube(CubeObject),
154    Image(ImageObject),
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct CubeObject {
159    pub position: [f64; 3],
160    #[serde(default = "default_scale")]
161    pub size: [f64; 3],
162    #[serde(default)]
163    pub color: Option<[f64; 4]>,
164    #[serde(default)]
165    pub add_collision: bool,
166    #[serde(default)]
167    pub material_type: Option<String>,
168    #[serde(default)]
169    pub roughness: Option<f64>,
170    #[serde(default)]
171    pub metallic: Option<f64>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ImageObject {
176    pub source: String,
177    pub position: [f64; 3],
178    #[serde(default)]
179    pub rotation: [f64; 3],
180    #[serde(default = "default_scale")]
181    pub scale: [f64; 3],
182    #[serde(default)]
183    pub interactive: bool,
184    #[serde(default = "default_true")]
185    pub visible: bool,
186    /// Optional back-and-forth patrol motion along one world axis (`find_target_patrol`).
187    /// Declares the contract only -- `mecha10-rl` (not this repo) actually advances the
188    /// object's position along the line during an episode. Absent for every other bundled
189    /// task's image objects, which stay stationary.
190    #[serde(default)]
191    pub motion: Option<ObjectMotion>,
192}
193
194/// Patrol-motion contract for an [`ImageObject`] (schema:
195/// `configs/simulation/schemas/environment-config.schema.json`'s `imageObject.motion`,
196/// published by `mecha10-assets`). Field names/shape are an agreed contract with the external
197/// `mecha10-rl` repo, which is what actually moves the object each step -- keep them in
198/// lockstep with that schema rather than renaming independently.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ObjectMotion {
201    #[serde(rename = "type")]
202    pub motion_type: MotionType,
203    pub axis: MotionAxis,
204    pub range_min: f64,
205    pub range_max: f64,
206    #[serde(default = "default_motion_speed")]
207    pub speed: f64,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum MotionType {
213    Patrol,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum MotionAxis {
219    X,
220    Y,
221}
222
223fn default_motion_speed() -> f64 {
224    1.0
225}
226
227fn default_scale() -> [f64; 3] {
228    [1.0, 1.0, 1.0]
229}
230
231fn default_true() -> bool {
232    true
233}
234
235impl EnvironmentConfig {
236    /// Load and parse an `environment.json` file from `path`.
237    pub fn load(path: &Path) -> Result<Self> {
238        super::load_json(path, "environment config")
239    }
240
241    /// All `type: "image"` billboard objects declared in this environment.
242    pub fn image_objects(&self) -> impl Iterator<Item = &ImageObject> {
243        self.features.objects.iter().filter_map(|obj| match obj {
244            SceneObject::Image(image) => Some(image),
245            SceneObject::Cube(_) => None,
246        })
247    }
248}
249
250impl ImageObject {
251    /// Validate this image object, resolving `source` relative to `repo_root` and confirming the
252    /// asset file actually exists on disk.
253    ///
254    /// This is the extent of "generates valid MuJoCo XML" verification achievable from this repo
255    /// (LAB-2025): we cannot invoke the external `mecha10_rl.mujoco.mjcf_generator` from here, so
256    /// this validates the config contract that generator depends on instead.
257    pub fn validate(&self, repo_root: &Path) -> Result<PathBuf> {
258        if self.source.trim().is_empty() {
259            bail!("image object has an empty `source` path");
260        }
261
262        let resolved = repo_root.join(&self.source);
263        if !resolved.is_file() {
264            bail!(
265                "image object `source` does not resolve to a file: {} (resolved: {})",
266                self.source,
267                resolved.display()
268            );
269        }
270
271        if self.scale.iter().any(|component| *component <= 0.0) {
272            bail!("image object `scale` components must be positive, got {:?}", self.scale);
273        }
274
275        Ok(resolved)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn repo_root() -> PathBuf {
284        Path::new(env!("CARGO_MANIFEST_DIR"))
285            .parent()
286            .and_then(Path::parent)
287            .expect("repo root")
288            .to_path_buf()
289    }
290
291    // The real `environment.json`/`preset.json`/`task.json` bundle used to live in-repo under
292    // `mecha10-templates/templates/configs/{simulation,rl}/`. That tree was extracted to the
293    // separate `mecha10-assets` repo and deleted from here (mecha-industries/mecha10#1348) - it's
294    // now served live via `mecha10-api`'s `GET /api/downloads/assets`, not readable from this
295    // crate at test time. The constants below are realistic hand-maintained copies of what
296    // `mecha10-assets` publishes (kept in lockstep by hand - there is no compile-time or
297    // test-time link to the real files anymore).
298    //
299    // `configs/simulation/images/aiko.jpg`/`phoebe.jpg` at this repo's own root are a separate,
300    // still-present, trimmed-down dev sample (not part of the deleted `mecha10-templates` tree) -
301    // tests that exercise `ImageObject::validate`'s on-disk file check still resolve against
302    // those real files below.
303
304    const BASIC_ARENA_ENVIRONMENT_JSON: &str = r#"{
305        "id": "basic_arena",
306        "version": "1.0.0",
307        "name": "Basic Arena",
308        "description": "Versatile starter arena with flat plane, walls, lighting, and multiple spawn points",
309        "scene_path": "res://packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/basic_arena.tscn",
310        "dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
311        "features": {
312            "floor": {"type": "flat", "material": "concrete", "friction": 0.8, "collision": true},
313            "walls": {"count": 4, "height": 3.0, "thickness": 0.5, "material": "concrete", "collision": true},
314            "lighting": {
315                "directional": {"enabled": true, "energy": 1.2, "shadows": true, "angle": "45_degrees"},
316                "ambient": {"enabled": true, "color": [0.8, 0.8, 0.8, 1.0], "source": "background"}
317            },
318            "spawn_points": {
319                "count": 5,
320                "positions": [[0, 5.0, 0], [-5, 5.0, -5], [5, 5.0, -5], [-5, 5.0, 5], [5, 5.0, 5]],
321                "default_index": 0
322            },
323            "obstacles": {"count": 5, "type": "mixed"},
324            "objects": [
325                {
326                    "type": "cube",
327                    "position": [3.0, 0.5, 3.0],
328                    "size": [1.0, 1.0, 1.0],
329                    "color": [0.8, 0.2, 0.2, 1.0],
330                    "add_collision": true,
331                    "material_type": "standard",
332                    "roughness": 0.8,
333                    "metallic": 0.0
334                },
335                {
336                    "type": "cube",
337                    "position": [-3.0, 0.5, 3.0],
338                    "size": [0.8, 0.8, 0.8],
339                    "color": [0.2, 0.8, 0.2, 1.0],
340                    "add_collision": true,
341                    "material_type": "standard",
342                    "roughness": 0.8,
343                    "metallic": 0.0
344                }
345            ],
346            "goals": {"count": 0, "type": "none"},
347            "npcs": {"enabled": false, "count": 0}
348        }
349    }"#;
350
351    const HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON: &str = r#"{
352        "id": "home_outdoor_urban",
353        "version": "1.0.0",
354        "name": "Home & Outdoor Urban",
355        "description": "Realistic mixed-use environment: a partitioned home interior opening onto an outdoor urban street.",
356        "scene_path": "res://packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/home_outdoor_urban.tscn",
357        "dimensions": {"width": 34.0, "length": 20.0, "height": 4.0, "unit": "meters"},
358        "features": {
359            "floor": {"type": "flat", "material": "concrete", "friction": 0.85, "collision": true},
360            "walls": {"count": 4, "height": 3.2, "thickness": 0.3, "material": "brick", "collision": true},
361            "lighting": {
362                "directional": {"enabled": true, "energy": 1.4, "shadows": true, "angle": "60_degrees"},
363                "ambient": {"enabled": true, "color": [0.75, 0.78, 0.85, 1.0], "source": "sky"}
364            },
365            "spawn_points": {
366                "count": 4,
367                "positions": [[-12.0, 0.5, 0.0], [-1.0, 0.5, 0.0], [5.0, 0.5, -3.0], [12.0, 0.5, 6.0]],
368                "default_index": 0
369            },
370            "obstacles": {"count": 18, "type": "mixed"},
371            "objects": [
372                {
373                    "type": "cube",
374                    "position": [-9.0, 1.6, -4.0],
375                    "size": [0.3, 3.2, 10.0],
376                    "color": [0.7, 0.65, 0.6, 1.0],
377                    "add_collision": true,
378                    "material_type": "standard",
379                    "roughness": 0.9,
380                    "metallic": 0.0
381                },
382                {
383                    "type": "cube",
384                    "position": [-5.0, 1.6, 2.0],
385                    "size": [8.0, 3.2, 0.3],
386                    "color": [0.7, 0.65, 0.6, 1.0],
387                    "add_collision": true,
388                    "material_type": "standard",
389                    "roughness": 0.9,
390                    "metallic": 0.0
391                },
392                {
393                    "type": "cube",
394                    "position": [-13.0, 0.4, -6.0],
395                    "size": [2.2, 0.8, 0.9],
396                    "color": [0.35, 0.25, 0.2, 1.0],
397                    "add_collision": true,
398                    "material_type": "standard",
399                    "roughness": 0.8,
400                    "metallic": 0.0
401                },
402                {
403                    "type": "cube",
404                    "position": [-12.0, 0.25, -3.5],
405                    "size": [1.0, 0.5, 0.6],
406                    "color": [0.4, 0.3, 0.2, 1.0],
407                    "add_collision": true,
408                    "material_type": "standard",
409                    "roughness": 0.7,
410                    "metallic": 0.0
411                },
412                {
413                    "type": "cube",
414                    "position": [-7.0, 0.4, 5.0],
415                    "size": [1.6, 0.8, 1.0],
416                    "color": [0.5, 0.35, 0.2, 1.0],
417                    "add_collision": true,
418                    "material_type": "standard",
419                    "roughness": 0.7,
420                    "metallic": 0.0
421                },
422                {
423                    "type": "cube",
424                    "position": [-4.0, 0.45, 6.0],
425                    "size": [1.8, 0.9, 0.8],
426                    "color": [0.85, 0.85, 0.85, 1.0],
427                    "add_collision": true,
428                    "material_type": "standard",
429                    "roughness": 0.4,
430                    "metallic": 0.1
431                },
432                {
433                    "type": "cube",
434                    "position": [-14.0, 0.3, 4.0],
435                    "size": [1.6, 0.6, 2.0],
436                    "color": [0.6, 0.6, 0.7, 1.0],
437                    "add_collision": true,
438                    "material_type": "standard",
439                    "roughness": 0.8,
440                    "metallic": 0.0
441                },
442                {
443                    "type": "cube",
444                    "position": [8.0, 0.6, -7.0],
445                    "size": [4.2, 1.2, 1.8],
446                    "color": [0.15, 0.15, 0.2, 1.0],
447                    "add_collision": true,
448                    "material_type": "metallic",
449                    "roughness": 0.3,
450                    "metallic": 0.7
451                },
452                {
453                    "type": "cube",
454                    "position": [3.0, 2.0, 8.0],
455                    "size": [0.25, 4.0, 0.25],
456                    "color": [0.2, 0.2, 0.2, 1.0],
457                    "add_collision": true,
458                    "material_type": "standard",
459                    "roughness": 0.6,
460                    "metallic": 0.2
461                },
462                {
463                    "type": "cube",
464                    "position": [10.0, 0.25, 8.0],
465                    "size": [1.6, 0.5, 0.5],
466                    "color": [0.3, 0.2, 0.1, 1.0],
467                    "add_collision": true,
468                    "material_type": "standard",
469                    "roughness": 0.9,
470                    "metallic": 0.0
471                }
472            ],
473            "goals": {"count": 2, "type": "waypoint"},
474            "npcs": {"enabled": true, "count": 2}
475        }
476    }"#;
477
478    const MOVING_OBJECTS_ARENA_ENVIRONMENT_JSON: &str = r#"{
479        "id": "moving_objects_arena",
480        "version": "1.0.0",
481        "name": "Moving Objects Arena",
482        "description": "basic_arena's generic arena with image objects that patrol back and forth instead of sitting still.",
483        "scene_path": "res://packages/mecha10-templates/templates/configs/simulation/environments/moving_objects_arena/moving_objects_arena.tscn",
484        "dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
485        "features": {
486            "floor": {"type": "flat", "material": "concrete", "friction": 0.8, "collision": true},
487            "walls": {"count": 4, "height": 3.0, "thickness": 0.5, "material": "concrete", "collision": true},
488            "lighting": {
489                "directional": {"enabled": true, "energy": 1.2, "shadows": true, "angle": "45_degrees"},
490                "ambient": {"enabled": true, "color": [0.8, 0.8, 0.8, 1.0], "source": "background"}
491            },
492            "spawn_points": {
493                "count": 5,
494                "positions": [[0, 5.0, 0], [-5, 5.0, -5], [5, 5.0, -5], [-5, 5.0, 5], [5, 5.0, 5]],
495                "default_index": 0
496            },
497            "obstacles": {"count": 0, "type": "none"},
498            "objects": [
499                {
500                    "type": "image",
501                    "source": "configs/simulation/images/aiko.jpg",
502                    "position": [-6.0, 0.5, -4.0],
503                    "rotation": [0, 90, 0],
504                    "scale": [1.0, 1.0, 1.0],
505                    "interactive": false,
506                    "visible": true,
507                    "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
508                },
509                {
510                    "type": "image",
511                    "source": "configs/simulation/images/phoebe.jpg",
512                    "position": [6.0, 0.5, 2.0],
513                    "rotation": [0, -90, 0],
514                    "scale": [1.0, 1.0, 1.0],
515                    "interactive": false,
516                    "visible": true,
517                    "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.6}
518                }
519            ],
520            "goals": {"count": 0, "type": "none"},
521            "npcs": {"enabled": false, "count": 0}
522        }
523    }"#;
524
525    const CATS_PRESET_JSON: &str = r#"{
526        "id": "cats",
527        "version": "1.0.0",
528        "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.",
529        "objects": [
530            {
531                "type": "image",
532                "source": "configs/simulation/images/aiko.jpg",
533                "position": [-3.0, 0.5, 0.0],
534                "rotation": [0, 90, 0],
535                "scale": [1.0, 1.0, 1.0],
536                "interactive": false,
537                "visible": true
538            },
539            {
540                "type": "image",
541                "source": "configs/simulation/images/phoebe.jpg",
542                "position": [3.0, 0.5, 1.5],
543                "rotation": [0, -90, 0],
544                "scale": [1.0, 1.0, 1.0],
545                "interactive": false,
546                "visible": true
547            }
548        ]
549    }"#;
550
551    const OBJECTS_PATROL_PRESET_JSON: &str = r#"{
552        "id": "objects_patrol",
553        "version": "1.0.0",
554        "description": "Same mixed image set as the `objects` preset, but every object declares a patrol `motion` block instead of sitting still - used by find_target_patrol.",
555        "objects": [
556            {
557                "type": "image",
558                "source": "configs/simulation/images/aiko.jpg",
559                "position": [-3.0, 0.5, 0.0],
560                "rotation": [0, 90, 0],
561                "scale": [1.0, 1.0, 1.0],
562                "interactive": false,
563                "visible": true,
564                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
565            },
566            {
567                "type": "image",
568                "source": "configs/simulation/images/phoebe.jpg",
569                "position": [3.0, 0.5, 1.5],
570                "rotation": [0, -116.6, 0],
571                "scale": [1.0, 1.0, 1.0],
572                "interactive": false,
573                "visible": true,
574                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
575            },
576            {
577                "type": "image",
578                "source": "configs/simulation/images/dog1.jpg",
579                "position": [0.0, 0.5, 3.0],
580                "rotation": [0, 180, 0],
581                "scale": [1.0, 1.0, 1.0],
582                "interactive": false,
583                "visible": true,
584                "motion": {"type": "patrol", "axis": "x", "range_min": -8.0, "range_max": 8.0, "speed": 1.0}
585            }
586        ]
587    }"#;
588
589    const FIND_TARGET_TASK_JSON: &str = r#"{
590        "id": "find_target",
591        "version": "1.0.0",
592        "env_name": "basic_arena",
593        "goal_type": "find",
594        "target": "cat",
595        "preset": "cats",
596        "room_size": 8.0,
597        "target_range_min": 3.0,
598        "target_range_max": 4.0,
599        "goal_radius": 0.15,
600        "obstacle_count": 0,
601        "obstacle_radius": 0.3,
602        "max_episode_steps": 200,
603        "action_duration_s": 0.25,
604        "target_hold_steps": 10,
605        "max_linear_speed": 0.3,
606        "max_angular_speed": 1.0
607    }"#;
608
609    const DEFAULT_TASK_JSON: &str = r#"{
610        "id": "default",
611        "version": "1.0.0",
612        "env_name": "basic_arena",
613        "model": "rover",
614        "goal_type": "none",
615        "preset": "default",
616        "max_episode_steps": 200,
617        "action_duration_s": 0.25,
618        "max_linear_speed": 0.3,
619        "max_angular_speed": 1.0
620    }"#;
621
622    const FIND_TARGET_PATROL_TASK_JSON: &str = r#"{
623        "id": "find_target_patrol",
624        "version": "1.0.0",
625        "env_name": "basic_arena",
626        "goal_type": "find",
627        "target": "cat",
628        "targets": ["cat", "dog", "car", "bicycle", "bird", "bottle", "chair", "backpack"],
629        "preset": "objects_patrol",
630        "room_size": 8.0,
631        "target_range_min": 3.0,
632        "target_range_max": 4.0,
633        "goal_radius": 0.15,
634        "obstacle_count": 0,
635        "obstacle_radius": 0.3,
636        "max_episode_steps": 200,
637        "action_duration_s": 0.25,
638        "target_hold_steps": 5,
639        "center_margin": 0.2,
640        "max_linear_speed": 0.3,
641        "max_angular_speed": 1.0
642    }"#;
643
644    #[test]
645    fn parses_cube_object() {
646        let json = r#"{
647            "type": "cube",
648            "position": [1.0, 0.5, 2.0],
649            "size": [1.0, 1.0, 1.0],
650            "color": [0.8, 0.2, 0.2, 1.0],
651            "add_collision": true,
652            "material_type": "standard",
653            "roughness": 0.8,
654            "metallic": 0.0
655        }"#;
656        let obj: SceneObject = serde_json::from_str(json).unwrap();
657        match obj {
658            SceneObject::Cube(cube) => {
659                assert_eq!(cube.position, [1.0, 0.5, 2.0]);
660                assert!(cube.add_collision);
661            }
662            SceneObject::Image(_) => panic!("expected cube"),
663        }
664    }
665
666    #[test]
667    fn parses_cube_object_with_wood_material_type() {
668        // "wood" was added alongside "concrete"/"brick"/"metal" as a recognized arena material
669        // (companion schema change to the mecha10-rl procedural-texture renderer); this struct
670        // has never enforced an enum on `material_type` (that's the schema's job), but this
671        // pins that the new value keeps parsing cleanly.
672        let json = r#"{
673            "type": "cube",
674            "position": [1.0, 0.5, 2.0],
675            "size": [1.0, 1.0, 1.0],
676            "add_collision": false,
677            "material_type": "wood"
678        }"#;
679        let obj: SceneObject = serde_json::from_str(json).unwrap();
680        match obj {
681            SceneObject::Cube(cube) => {
682                assert_eq!(cube.material_type, Some("wood".to_string()));
683            }
684            SceneObject::Image(_) => panic!("expected cube"),
685        }
686    }
687
688    #[test]
689    fn parses_image_object() {
690        let json = r#"{
691            "type": "image",
692            "source": "configs/simulation/images/aiko.jpg",
693            "position": [0.0, 0.5, 3.5],
694            "rotation": [0, 180, 0],
695            "scale": [2.5, 2.5, 2.5],
696            "interactive": false,
697            "visible": true
698        }"#;
699        let obj: SceneObject = serde_json::from_str(json).unwrap();
700        match obj {
701            SceneObject::Image(image) => {
702                assert_eq!(image.source, "configs/simulation/images/aiko.jpg");
703                assert_eq!(image.position, [0.0, 0.5, 3.5]);
704                assert_eq!(image.rotation, [0.0, 180.0, 0.0]);
705                assert_eq!(image.scale, [2.5, 2.5, 2.5]);
706                assert!(!image.interactive);
707                assert!(image.visible);
708            }
709            SceneObject::Cube(_) => panic!("expected image"),
710        }
711    }
712
713    #[test]
714    fn image_object_defaults() {
715        let json = r#"{
716            "type": "image",
717            "source": "assets/waypoint.png",
718            "position": [1.0, 1.0, 1.0]
719        }"#;
720        let obj: SceneObject = serde_json::from_str(json).unwrap();
721        match obj {
722            SceneObject::Image(image) => {
723                assert_eq!(image.rotation, [0.0, 0.0, 0.0]);
724                assert_eq!(image.scale, [1.0, 1.0, 1.0]);
725                assert!(!image.interactive);
726                assert!(image.visible);
727            }
728            SceneObject::Cube(_) => panic!("expected image"),
729        }
730    }
731
732    #[test]
733    fn basic_arena_environment_stays_generic_with_no_task_specific_image_objects() {
734        let config: EnvironmentConfig =
735            serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
736
737        assert_eq!(config.id, "basic_arena");
738
739        assert!(
740            config.image_objects().next().is_none(),
741            "basic_arena should declare no image objects - those are task-specific, got: {:?}",
742            config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
743        );
744    }
745
746    #[test]
747    fn find_target_task_references_its_cats_preset() {
748        let value: serde_json::Value =
749            serde_json::from_str(FIND_TARGET_TASK_JSON).expect("find_target/task.json should parse");
750
751        assert_eq!(
752            value["preset"], "cats",
753            "find_target should reference its 'cats' preset (LAB-3629) rather than authoring objects[] inline"
754        );
755        assert!(
756            value.get("objects").is_none(),
757            "find_target/task.json should no longer declare an inline objects[] key"
758        );
759    }
760
761    #[test]
762    fn default_task_references_its_default_preset() {
763        let value: serde_json::Value = serde_json::from_str(DEFAULT_TASK_JSON).expect("default/task.json should parse");
764
765        assert_eq!(
766            value["env_name"], "basic_arena",
767            "default task must declare which environment its 'default' preset resolves against"
768        );
769        assert_eq!(
770            value["preset"], "default",
771            "default task should reference its 'default' preset (LAB-3630) rather than authoring objects[] inline"
772        );
773        assert!(
774            value.get("objects").is_none(),
775            "default/task.json should not declare an inline objects[] key"
776        );
777    }
778
779    #[test]
780    fn find_target_cats_preset_declares_its_own_image_objects() {
781        let root = repo_root();
782        let value: serde_json::Value = serde_json::from_str(CATS_PRESET_JSON).expect("preset.json should parse");
783        let objects: Vec<SceneObject> =
784            serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
785
786        let images: Vec<&ImageObject> = objects
787            .iter()
788            .filter_map(|obj| match obj {
789                SceneObject::Image(image) => Some(image),
790                SceneObject::Cube(_) => None,
791            })
792            .collect();
793
794        let aiko = images
795            .iter()
796            .find(|image| image.source.ends_with("aiko.jpg"))
797            .expect("cats preset should declare an aiko.jpg image object");
798        let resolved = aiko.validate(&root).expect("aiko image object should validate");
799        assert!(resolved.is_file());
800        assert_eq!(aiko.position.len(), 3);
801        assert_eq!(aiko.rotation.len(), 3);
802        assert_eq!(aiko.scale.len(), 3);
803
804        assert!(
805            images.iter().any(|image| image.source.ends_with("phoebe.jpg")),
806            "cats preset should also declare a phoebe.jpg image object"
807        );
808    }
809
810    #[test]
811    fn parses_image_object_with_motion_block() {
812        let json = r#"{
813            "type": "image",
814            "source": "configs/simulation/images/aiko.jpg",
815            "position": [-3.0, 0.5, 0.0],
816            "motion": {
817                "type": "patrol",
818                "axis": "x",
819                "range_min": -8.0,
820                "range_max": 8.0,
821                "speed": 1.0
822            }
823        }"#;
824        let obj: SceneObject = serde_json::from_str(json).unwrap();
825        match obj {
826            SceneObject::Image(image) => {
827                let motion = image.motion.expect("motion block should round-trip");
828                assert_eq!(motion.motion_type, MotionType::Patrol);
829                assert_eq!(motion.axis, MotionAxis::X);
830                assert_eq!(motion.range_min, -8.0);
831                assert_eq!(motion.range_max, 8.0);
832                assert_eq!(motion.speed, 1.0);
833            }
834            SceneObject::Cube(_) => panic!("expected image"),
835        }
836    }
837
838    #[test]
839    fn image_object_motion_defaults_to_none_and_speed_defaults_to_one() {
840        let no_motion: SceneObject =
841            serde_json::from_str(r#"{"type": "image", "source": "assets/waypoint.png", "position": [1.0, 1.0, 1.0]}"#)
842                .unwrap();
843        match no_motion {
844            SceneObject::Image(image) => assert!(image.motion.is_none()),
845            SceneObject::Cube(_) => panic!("expected image"),
846        }
847
848        let motion_without_speed: SceneObject = serde_json::from_str(
849            r#"{
850                "type": "image",
851                "source": "assets/waypoint.png",
852                "position": [1.0, 1.0, 1.0],
853                "motion": {"type": "patrol", "axis": "y", "range_min": -2.0, "range_max": 2.0}
854            }"#,
855        )
856        .unwrap();
857        match motion_without_speed {
858            SceneObject::Image(image) => {
859                let motion = image.motion.expect("motion block should round-trip");
860                assert_eq!(motion.axis, MotionAxis::Y);
861                assert_eq!(motion.speed, 1.0, "speed should default to 1.0 when omitted");
862            }
863            SceneObject::Cube(_) => panic!("expected image"),
864        }
865    }
866
867    #[test]
868    fn find_target_patrol_task_references_its_objects_patrol_preset() {
869        let value: serde_json::Value =
870            serde_json::from_str(FIND_TARGET_PATROL_TASK_JSON).expect("find_target_patrol/task.json should parse");
871        assert_eq!(
872            value["env_name"], "basic_arena",
873            "find_target_patrol must reuse basic_arena"
874        );
875        assert_eq!(
876            value["preset"], "objects_patrol",
877            "find_target_patrol should reference its 'objects_patrol' preset (LAB-3629) rather than authoring objects[] inline"
878        );
879        assert!(
880            value.get("objects").is_none(),
881            "find_target_patrol/task.json should no longer declare an inline objects[] key"
882        );
883    }
884
885    #[test]
886    fn objects_patrol_preset_declares_patrol_motion_on_its_image_objects() {
887        let root = repo_root();
888        let value: serde_json::Value =
889            serde_json::from_str(OBJECTS_PATROL_PRESET_JSON).expect("preset.json should parse");
890
891        let objects: Vec<SceneObject> =
892            serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
893
894        let images: Vec<&ImageObject> = objects
895            .iter()
896            .filter_map(|obj| match obj {
897                SceneObject::Image(image) => Some(image),
898                SceneObject::Cube(_) => None,
899            })
900            .collect();
901
902        assert!(!images.is_empty(), "find_target_patrol should declare image objects");
903
904        for image in &images {
905            // The full `mecha10-templates`-bundled images directory that used to ship every
906            // asset these presets reference (dog1.jpg et al.) was deleted along with the rest
907            // of `configs/simulation/` (mecha-industries/mecha10#1348) and now lives only in the
908            // external `mecha10-assets` repo, unreachable from here - so the on-disk
909            // `ImageObject::validate` file-exists check can only still be exercised for the
910            // handful of images that happen to also exist as this repo's own trimmed-down dev
911            // sample under `configs/simulation/images/` (`aiko.jpg`/`phoebe.jpg`). Every other
912            // image's motion contract is still fully checked below; only the file-exists check
913            // is skipped for it.
914            if image.source.ends_with("aiko.jpg") || image.source.ends_with("phoebe.jpg") {
915                let resolved = image
916                    .validate(&root)
917                    .expect("patrol image object with a locally-available asset should validate");
918                assert!(resolved.is_file());
919            }
920
921            let motion = image
922                .motion
923                .as_ref()
924                .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
925            assert_eq!(motion.motion_type, MotionType::Patrol);
926            assert_eq!(motion.axis, MotionAxis::X);
927            assert!(
928                motion.range_min < motion.range_max,
929                "range_min should be less than range_max for {}",
930                image.source
931            );
932            // basic_arena is a 20x20m room with 0.5m-thick walls (half-extent 10, wall
933            // half-thickness 0.25) - the patrol range must stay clear of both walls.
934            assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
935        }
936    }
937
938    #[test]
939    fn moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles() {
940        let root = repo_root();
941        let config: EnvironmentConfig = serde_json::from_str(MOVING_OBJECTS_ARENA_ENVIRONMENT_JSON)
942            .expect("moving_objects_arena environment.json should parse");
943
944        assert_eq!(config.id, "moving_objects_arena");
945
946        let images: Vec<&ImageObject> = config.image_objects().collect();
947        assert!(
948            images.len() >= 2,
949            "moving_objects_arena should declare at least two moving image objects, got {}",
950            images.len()
951        );
952
953        for image in &images {
954            let resolved = image
955                .validate(&root)
956                .unwrap_or_else(|e| panic!("{} should validate: {e}", image.source));
957            assert!(resolved.is_file());
958
959            let motion = image
960                .motion
961                .as_ref()
962                .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
963            assert_eq!(motion.motion_type, MotionType::Patrol);
964            assert!(
965                motion.range_min < motion.range_max,
966                "range_min should be less than range_max for {}",
967                image.source
968            );
969            // 20x20m room with 0.5m-thick walls (half-extent 10, wall half-thickness 0.25) - the
970            // patrol range must stay clear of both walls.
971            assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
972        }
973
974        assert!(
975            config
976                .features
977                .obstacles
978                .as_ref()
979                .map_or(true, |obstacles| obstacles.count == 0),
980            "moving_objects_arena should have no static obstacles cluttering the moving-target test"
981        );
982    }
983
984    #[test]
985    fn validate_rejects_missing_source_file() {
986        let root = repo_root();
987        let image = ImageObject {
988            source: "configs/simulation/images/does_not_exist.jpg".to_string(),
989            position: [0.0, 0.0, 0.0],
990            rotation: [0.0, 0.0, 0.0],
991            scale: [1.0, 1.0, 1.0],
992            interactive: false,
993            visible: true,
994            motion: None,
995        };
996        let err = image
997            .validate(&root)
998            .expect_err("missing source file should fail validation");
999        assert!(err.to_string().contains("does not resolve to a file"));
1000    }
1001
1002    /// Minimal document satisfying every field the hand-written schema marks `required`.
1003    fn minimal_environment_json() -> serde_json::Value {
1004        serde_json::json!({
1005            "id": "basic_arena",
1006            "version": "1.0.0",
1007            "name": "Basic Arena",
1008            "description": "A test arena",
1009            "scene_path": "res://basic_arena.tscn",
1010            "dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
1011            "features": {}
1012        })
1013    }
1014
1015    #[test]
1016    fn parses_a_minimal_valid_environment_config() {
1017        let config: EnvironmentConfig = serde_json::from_value(minimal_environment_json()).unwrap();
1018        assert_eq!(config.scene_path, "res://basic_arena.tscn");
1019        assert_eq!(config.dimensions.width, 20.0);
1020        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1021        assert!(config.features.floor.is_none());
1022    }
1023
1024    #[test]
1025    fn rejects_environment_config_missing_scene_path() {
1026        let mut value = minimal_environment_json();
1027        value.as_object_mut().unwrap().remove("scene_path");
1028        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1029        assert!(err.to_string().contains("scene_path"), "{err}");
1030    }
1031
1032    #[test]
1033    fn rejects_environment_config_missing_dimensions() {
1034        let mut value = minimal_environment_json();
1035        value.as_object_mut().unwrap().remove("dimensions");
1036        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1037        assert!(err.to_string().contains("dimensions"), "{err}");
1038    }
1039
1040    #[test]
1041    fn rejects_malformed_floor_feature() {
1042        let mut value = minimal_environment_json();
1043        value["features"]["floor"] = serde_json::json!({
1044            "type": "flat",
1045            "material": "concrete",
1046            "friction": 0.8
1047        });
1048        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1049        assert!(err.to_string().contains("collision"), "{err}");
1050    }
1051
1052    #[test]
1053    fn parses_full_basic_arena_environment_including_dimensions_and_features() {
1054        let config: EnvironmentConfig =
1055            serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
1056
1057        assert_eq!(
1058            config.scene_path,
1059            "res://packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/basic_arena.tscn"
1060        );
1061        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1062
1063        let floor = config.features.floor.expect("basic_arena should declare a floor");
1064        assert_eq!(floor.material, "concrete");
1065        assert!(floor.collision);
1066
1067        let walls = config.features.walls.expect("basic_arena should declare walls");
1068        assert_eq!(walls.count, 4);
1069
1070        let lighting = config.features.lighting.expect("basic_arena should declare lighting");
1071        assert!(lighting.directional.enabled);
1072        assert!(lighting.ambient.enabled);
1073
1074        let spawn_points = config
1075            .features
1076            .spawn_points
1077            .expect("basic_arena should declare spawn points");
1078        assert_eq!(spawn_points.count, 5);
1079        assert_eq!(spawn_points.positions.len(), 5);
1080
1081        let obstacles = config.features.obstacles.expect("basic_arena should declare obstacles");
1082        assert_eq!(obstacles.obstacle_type, "mixed");
1083
1084        let goals = config.features.goals.expect("basic_arena should declare goals");
1085        assert_eq!(goals.count, 0);
1086
1087        let npcs = config.features.npcs.expect("basic_arena should declare npcs");
1088        assert!(!npcs.enabled);
1089    }
1090
1091    #[test]
1092    fn home_outdoor_urban_environment_stays_generic_with_no_task_specific_image_objects() {
1093        let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1094            .expect("home_outdoor_urban environment.json should parse");
1095
1096        assert_eq!(config.id, "home_outdoor_urban");
1097
1098        assert!(
1099            config.image_objects().next().is_none(),
1100            "home_outdoor_urban should declare no image objects - those are task-specific, got: {:?}",
1101            config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1102        );
1103    }
1104
1105    #[test]
1106    fn every_bundled_environment_is_structural_only_except_the_tracked_moving_objects_arena_exception() {
1107        // Environments model ground/walls/lighting/obstacles, not task content - `cat_environment`
1108        // violated this by baking cat image billboards directly into `environment.json` (LAB-3628)
1109        // and was deleted for it. This test used to walk every environment bundled under
1110        // `templates/configs/simulation/environments` on disk so a future environment couldn't
1111        // reintroduce the same mistake without this test catching it.
1112        //
1113        // That directory was extracted to the external `mecha10-assets` repo and deleted from
1114        // here (mecha-industries/mecha10#1348), so this repo can no longer discover "every
1115        // bundled environment" by listing a local directory - `mecha10-assets` is the new
1116        // source of truth and isn't readable from this crate. This test is downgraded to
1117        // checking the known, hand-maintained fixtures above (`BASIC_ARENA_ENVIRONMENT_JSON`,
1118        // `HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON`) rather than silently dropped - it still catches
1119        // a regression on either of those two, but can no longer catch a *new* environment
1120        // added directly to `mecha10-assets` smuggling in task-specific image objects. That gap
1121        // is real and not covered by any test in this repo any more.
1122        //
1123        // `moving_objects_arena` is a known, tracked exception (see its own
1124        // `moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles` test
1125        // above and the sibling issue to migrate it off baked-in image objects) - it is
1126        // deliberately excluded here rather than silently allowed to multiply.
1127        let mut checked = Vec::new();
1128        for (name, fixture) in [
1129            ("basic_arena", BASIC_ARENA_ENVIRONMENT_JSON),
1130            ("home_outdoor_urban", HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON),
1131        ] {
1132            let config: EnvironmentConfig =
1133                serde_json::from_str(fixture).unwrap_or_else(|e| panic!("{name} should parse: {e}"));
1134            assert!(
1135                config.image_objects().next().is_none(),
1136                "{} should declare no image objects - environments are structural-only, task \
1137                 content belongs in task.json, got: {:?}",
1138                name,
1139                config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1140            );
1141            checked.push(name.to_string());
1142        }
1143
1144        assert!(
1145            checked.contains(&"basic_arena".to_string()) && checked.contains(&"home_outdoor_urban".to_string()),
1146            "expected to have actually checked the known structural-only environments, got: {checked:?}"
1147        );
1148    }
1149
1150    #[test]
1151    fn parses_full_home_outdoor_urban_environment_including_dimensions_and_features() {
1152        let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1153            .expect("home_outdoor_urban environment.json should parse");
1154
1155        assert_eq!(
1156            config.scene_path,
1157            "res://packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/home_outdoor_urban.tscn"
1158        );
1159        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1160
1161        // Bigger than basic_arena (20x20) - this environment needs room for
1162        // a partitioned home interior *and* a separate outdoor urban street section.
1163        assert!(config.dimensions.width > 20.0);
1164        assert!(config.dimensions.length > 0.0);
1165
1166        let floor = config
1167            .features
1168            .floor
1169            .expect("home_outdoor_urban should declare a floor");
1170        assert!(floor.collision);
1171
1172        let walls = config.features.walls.expect("home_outdoor_urban should declare walls");
1173        assert_eq!(walls.count, 4);
1174
1175        let lighting = config
1176            .features
1177            .lighting
1178            .expect("home_outdoor_urban should declare lighting");
1179        assert!(lighting.directional.enabled);
1180        assert!(lighting.ambient.enabled);
1181
1182        let spawn_points = config
1183            .features
1184            .spawn_points
1185            .expect("home_outdoor_urban should declare spawn points");
1186        assert_eq!(spawn_points.count, spawn_points.positions.len() as u32);
1187        assert!(
1188            spawn_points.count >= 2,
1189            "home_outdoor_urban should offer spawn points in both the home and outdoor sections"
1190        );
1191
1192        let goals = config.features.goals.expect("home_outdoor_urban should declare goals");
1193        assert!(
1194            goals.count > 0,
1195            "a navigation-focused environment should declare goal zones"
1196        );
1197
1198        let npcs = config.features.npcs.expect("home_outdoor_urban should declare npcs");
1199        assert!(npcs.enabled, "home_outdoor_urban should model dynamic pedestrians");
1200
1201        // Dense obstacle layout (interior partition walls + furniture + street furniture) is the
1202        // whole point of this environment - planning/navigation needs more to avoid than the
1203        // open basic_arena provides.
1204        assert!(
1205            config.features.objects.len() >= 10,
1206            "home_outdoor_urban should be densely furnished for navigation testing, got {} objects",
1207            config.features.objects.len()
1208        );
1209        assert!(
1210            config
1211                .features
1212                .objects
1213                .iter()
1214                .all(|obj| matches!(obj, SceneObject::Cube(_))),
1215            "home_outdoor_urban's structural/furniture objects should all be cubes, not task-specific images"
1216        );
1217    }
1218
1219    #[test]
1220    fn validate_rejects_zero_scale() {
1221        let root = repo_root();
1222        let image = ImageObject {
1223            source: "configs/simulation/images/aiko.jpg".to_string(),
1224            position: [0.0, 0.0, 0.0],
1225            rotation: [0.0, 0.0, 0.0],
1226            scale: [0.0, 1.0, 1.0],
1227            interactive: false,
1228            visible: true,
1229            motion: None,
1230        };
1231        let err = image.validate(&root).expect_err("zero scale should fail validation");
1232        assert!(err.to_string().contains("scale"));
1233    }
1234}