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        "model": "rover",
594        "goal_type": "find",
595        "target": "cat",
596        "preset": "cats",
597        "room_size": 8.0,
598        "target_range_min": 3.0,
599        "target_range_max": 4.0,
600        "goal_radius": 0.15,
601        "obstacle_count": 0,
602        "obstacle_radius": 0.3,
603        "max_episode_steps": 200,
604        "action_duration_s": 0.25,
605        "target_hold_steps": 10,
606        "max_linear_speed": 0.3,
607        "max_angular_speed": 1.0
608    }"#;
609
610    const DEFAULT_TASK_JSON: &str = r#"{
611        "id": "default",
612        "version": "1.0.0",
613        "env_name": "basic_arena",
614        "model": "rover",
615        "goal_type": "none",
616        "preset": "default",
617        "max_episode_steps": 200,
618        "action_duration_s": 0.25,
619        "max_linear_speed": 0.3,
620        "max_angular_speed": 1.0
621    }"#;
622
623    const FIND_TARGET_PATROL_TASK_JSON: &str = r#"{
624        "id": "find_target_patrol",
625        "version": "1.0.0",
626        "env_name": "basic_arena",
627        "model": "rover",
628        "goal_type": "find",
629        "target": "cat",
630        "targets": ["cat", "dog", "car", "bicycle", "bird", "bottle", "chair", "backpack"],
631        "preset": "objects_patrol",
632        "room_size": 8.0,
633        "target_range_min": 3.0,
634        "target_range_max": 4.0,
635        "goal_radius": 0.15,
636        "obstacle_count": 0,
637        "obstacle_radius": 0.3,
638        "max_episode_steps": 200,
639        "action_duration_s": 0.25,
640        "target_hold_steps": 5,
641        "center_margin": 0.2,
642        "max_linear_speed": 0.3,
643        "max_angular_speed": 1.0
644    }"#;
645
646    #[test]
647    fn parses_cube_object() {
648        let json = r#"{
649            "type": "cube",
650            "position": [1.0, 0.5, 2.0],
651            "size": [1.0, 1.0, 1.0],
652            "color": [0.8, 0.2, 0.2, 1.0],
653            "add_collision": true,
654            "material_type": "standard",
655            "roughness": 0.8,
656            "metallic": 0.0
657        }"#;
658        let obj: SceneObject = serde_json::from_str(json).unwrap();
659        match obj {
660            SceneObject::Cube(cube) => {
661                assert_eq!(cube.position, [1.0, 0.5, 2.0]);
662                assert!(cube.add_collision);
663            }
664            SceneObject::Image(_) => panic!("expected cube"),
665        }
666    }
667
668    #[test]
669    fn parses_image_object() {
670        let json = r#"{
671            "type": "image",
672            "source": "configs/simulation/images/aiko.jpg",
673            "position": [0.0, 0.5, 3.5],
674            "rotation": [0, 180, 0],
675            "scale": [2.5, 2.5, 2.5],
676            "interactive": false,
677            "visible": true
678        }"#;
679        let obj: SceneObject = serde_json::from_str(json).unwrap();
680        match obj {
681            SceneObject::Image(image) => {
682                assert_eq!(image.source, "configs/simulation/images/aiko.jpg");
683                assert_eq!(image.position, [0.0, 0.5, 3.5]);
684                assert_eq!(image.rotation, [0.0, 180.0, 0.0]);
685                assert_eq!(image.scale, [2.5, 2.5, 2.5]);
686                assert!(!image.interactive);
687                assert!(image.visible);
688            }
689            SceneObject::Cube(_) => panic!("expected image"),
690        }
691    }
692
693    #[test]
694    fn image_object_defaults() {
695        let json = r#"{
696            "type": "image",
697            "source": "assets/waypoint.png",
698            "position": [1.0, 1.0, 1.0]
699        }"#;
700        let obj: SceneObject = serde_json::from_str(json).unwrap();
701        match obj {
702            SceneObject::Image(image) => {
703                assert_eq!(image.rotation, [0.0, 0.0, 0.0]);
704                assert_eq!(image.scale, [1.0, 1.0, 1.0]);
705                assert!(!image.interactive);
706                assert!(image.visible);
707            }
708            SceneObject::Cube(_) => panic!("expected image"),
709        }
710    }
711
712    #[test]
713    fn basic_arena_environment_stays_generic_with_no_task_specific_image_objects() {
714        let config: EnvironmentConfig =
715            serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
716
717        assert_eq!(config.id, "basic_arena");
718
719        assert!(
720            config.image_objects().next().is_none(),
721            "basic_arena should declare no image objects - those are task-specific, got: {:?}",
722            config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
723        );
724    }
725
726    #[test]
727    fn find_target_task_references_its_cats_preset() {
728        let value: serde_json::Value =
729            serde_json::from_str(FIND_TARGET_TASK_JSON).expect("find_target/task.json should parse");
730
731        assert_eq!(
732            value["preset"], "cats",
733            "find_target should reference its 'cats' preset (LAB-3629) rather than authoring objects[] inline"
734        );
735        assert!(
736            value.get("objects").is_none(),
737            "find_target/task.json should no longer declare an inline objects[] key"
738        );
739    }
740
741    #[test]
742    fn default_task_references_its_default_preset() {
743        let value: serde_json::Value = serde_json::from_str(DEFAULT_TASK_JSON).expect("default/task.json should parse");
744
745        assert_eq!(
746            value["env_name"], "basic_arena",
747            "default task must declare which environment its 'default' preset resolves against"
748        );
749        assert_eq!(
750            value["preset"], "default",
751            "default task should reference its 'default' preset (LAB-3630) rather than authoring objects[] inline"
752        );
753        assert!(
754            value.get("objects").is_none(),
755            "default/task.json should not declare an inline objects[] key"
756        );
757    }
758
759    #[test]
760    fn find_target_cats_preset_declares_its_own_image_objects() {
761        let root = repo_root();
762        let value: serde_json::Value = serde_json::from_str(CATS_PRESET_JSON).expect("preset.json should parse");
763        let objects: Vec<SceneObject> =
764            serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
765
766        let images: Vec<&ImageObject> = objects
767            .iter()
768            .filter_map(|obj| match obj {
769                SceneObject::Image(image) => Some(image),
770                SceneObject::Cube(_) => None,
771            })
772            .collect();
773
774        let aiko = images
775            .iter()
776            .find(|image| image.source.ends_with("aiko.jpg"))
777            .expect("cats preset should declare an aiko.jpg image object");
778        let resolved = aiko.validate(&root).expect("aiko image object should validate");
779        assert!(resolved.is_file());
780        assert_eq!(aiko.position.len(), 3);
781        assert_eq!(aiko.rotation.len(), 3);
782        assert_eq!(aiko.scale.len(), 3);
783
784        assert!(
785            images.iter().any(|image| image.source.ends_with("phoebe.jpg")),
786            "cats preset should also declare a phoebe.jpg image object"
787        );
788    }
789
790    #[test]
791    fn parses_image_object_with_motion_block() {
792        let json = r#"{
793            "type": "image",
794            "source": "configs/simulation/images/aiko.jpg",
795            "position": [-3.0, 0.5, 0.0],
796            "motion": {
797                "type": "patrol",
798                "axis": "x",
799                "range_min": -8.0,
800                "range_max": 8.0,
801                "speed": 1.0
802            }
803        }"#;
804        let obj: SceneObject = serde_json::from_str(json).unwrap();
805        match obj {
806            SceneObject::Image(image) => {
807                let motion = image.motion.expect("motion block should round-trip");
808                assert_eq!(motion.motion_type, MotionType::Patrol);
809                assert_eq!(motion.axis, MotionAxis::X);
810                assert_eq!(motion.range_min, -8.0);
811                assert_eq!(motion.range_max, 8.0);
812                assert_eq!(motion.speed, 1.0);
813            }
814            SceneObject::Cube(_) => panic!("expected image"),
815        }
816    }
817
818    #[test]
819    fn image_object_motion_defaults_to_none_and_speed_defaults_to_one() {
820        let no_motion: SceneObject =
821            serde_json::from_str(r#"{"type": "image", "source": "assets/waypoint.png", "position": [1.0, 1.0, 1.0]}"#)
822                .unwrap();
823        match no_motion {
824            SceneObject::Image(image) => assert!(image.motion.is_none()),
825            SceneObject::Cube(_) => panic!("expected image"),
826        }
827
828        let motion_without_speed: SceneObject = serde_json::from_str(
829            r#"{
830                "type": "image",
831                "source": "assets/waypoint.png",
832                "position": [1.0, 1.0, 1.0],
833                "motion": {"type": "patrol", "axis": "y", "range_min": -2.0, "range_max": 2.0}
834            }"#,
835        )
836        .unwrap();
837        match motion_without_speed {
838            SceneObject::Image(image) => {
839                let motion = image.motion.expect("motion block should round-trip");
840                assert_eq!(motion.axis, MotionAxis::Y);
841                assert_eq!(motion.speed, 1.0, "speed should default to 1.0 when omitted");
842            }
843            SceneObject::Cube(_) => panic!("expected image"),
844        }
845    }
846
847    #[test]
848    fn find_target_patrol_task_references_its_objects_patrol_preset() {
849        let value: serde_json::Value =
850            serde_json::from_str(FIND_TARGET_PATROL_TASK_JSON).expect("find_target_patrol/task.json should parse");
851        assert_eq!(
852            value["env_name"], "basic_arena",
853            "find_target_patrol must reuse basic_arena"
854        );
855        assert_eq!(
856            value["preset"], "objects_patrol",
857            "find_target_patrol should reference its 'objects_patrol' preset (LAB-3629) rather than authoring objects[] inline"
858        );
859        assert!(
860            value.get("objects").is_none(),
861            "find_target_patrol/task.json should no longer declare an inline objects[] key"
862        );
863    }
864
865    #[test]
866    fn objects_patrol_preset_declares_patrol_motion_on_its_image_objects() {
867        let root = repo_root();
868        let value: serde_json::Value =
869            serde_json::from_str(OBJECTS_PATROL_PRESET_JSON).expect("preset.json should parse");
870
871        let objects: Vec<SceneObject> =
872            serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
873
874        let images: Vec<&ImageObject> = objects
875            .iter()
876            .filter_map(|obj| match obj {
877                SceneObject::Image(image) => Some(image),
878                SceneObject::Cube(_) => None,
879            })
880            .collect();
881
882        assert!(!images.is_empty(), "find_target_patrol should declare image objects");
883
884        for image in &images {
885            // The full `mecha10-templates`-bundled images directory that used to ship every
886            // asset these presets reference (dog1.jpg et al.) was deleted along with the rest
887            // of `configs/simulation/` (mecha-industries/mecha10#1348) and now lives only in the
888            // external `mecha10-assets` repo, unreachable from here - so the on-disk
889            // `ImageObject::validate` file-exists check can only still be exercised for the
890            // handful of images that happen to also exist as this repo's own trimmed-down dev
891            // sample under `configs/simulation/images/` (`aiko.jpg`/`phoebe.jpg`). Every other
892            // image's motion contract is still fully checked below; only the file-exists check
893            // is skipped for it.
894            if image.source.ends_with("aiko.jpg") || image.source.ends_with("phoebe.jpg") {
895                let resolved = image
896                    .validate(&root)
897                    .expect("patrol image object with a locally-available asset should validate");
898                assert!(resolved.is_file());
899            }
900
901            let motion = image
902                .motion
903                .as_ref()
904                .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
905            assert_eq!(motion.motion_type, MotionType::Patrol);
906            assert_eq!(motion.axis, MotionAxis::X);
907            assert!(
908                motion.range_min < motion.range_max,
909                "range_min should be less than range_max for {}",
910                image.source
911            );
912            // basic_arena is a 20x20m room with 0.5m-thick walls (half-extent 10, wall
913            // half-thickness 0.25) - the patrol range must stay clear of both walls.
914            assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
915        }
916    }
917
918    #[test]
919    fn moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles() {
920        let root = repo_root();
921        let config: EnvironmentConfig = serde_json::from_str(MOVING_OBJECTS_ARENA_ENVIRONMENT_JSON)
922            .expect("moving_objects_arena environment.json should parse");
923
924        assert_eq!(config.id, "moving_objects_arena");
925
926        let images: Vec<&ImageObject> = config.image_objects().collect();
927        assert!(
928            images.len() >= 2,
929            "moving_objects_arena should declare at least two moving image objects, got {}",
930            images.len()
931        );
932
933        for image in &images {
934            let resolved = image
935                .validate(&root)
936                .unwrap_or_else(|e| panic!("{} should validate: {e}", image.source));
937            assert!(resolved.is_file());
938
939            let motion = image
940                .motion
941                .as_ref()
942                .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
943            assert_eq!(motion.motion_type, MotionType::Patrol);
944            assert!(
945                motion.range_min < motion.range_max,
946                "range_min should be less than range_max for {}",
947                image.source
948            );
949            // 20x20m room with 0.5m-thick walls (half-extent 10, wall half-thickness 0.25) - the
950            // patrol range must stay clear of both walls.
951            assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
952        }
953
954        assert!(
955            config
956                .features
957                .obstacles
958                .as_ref()
959                .map_or(true, |obstacles| obstacles.count == 0),
960            "moving_objects_arena should have no static obstacles cluttering the moving-target test"
961        );
962    }
963
964    #[test]
965    fn validate_rejects_missing_source_file() {
966        let root = repo_root();
967        let image = ImageObject {
968            source: "configs/simulation/images/does_not_exist.jpg".to_string(),
969            position: [0.0, 0.0, 0.0],
970            rotation: [0.0, 0.0, 0.0],
971            scale: [1.0, 1.0, 1.0],
972            interactive: false,
973            visible: true,
974            motion: None,
975        };
976        let err = image
977            .validate(&root)
978            .expect_err("missing source file should fail validation");
979        assert!(err.to_string().contains("does not resolve to a file"));
980    }
981
982    /// Minimal document satisfying every field the hand-written schema marks `required`.
983    fn minimal_environment_json() -> serde_json::Value {
984        serde_json::json!({
985            "id": "basic_arena",
986            "version": "1.0.0",
987            "name": "Basic Arena",
988            "description": "A test arena",
989            "scene_path": "res://basic_arena.tscn",
990            "dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
991            "features": {}
992        })
993    }
994
995    #[test]
996    fn parses_a_minimal_valid_environment_config() {
997        let config: EnvironmentConfig = serde_json::from_value(minimal_environment_json()).unwrap();
998        assert_eq!(config.scene_path, "res://basic_arena.tscn");
999        assert_eq!(config.dimensions.width, 20.0);
1000        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1001        assert!(config.features.floor.is_none());
1002    }
1003
1004    #[test]
1005    fn rejects_environment_config_missing_scene_path() {
1006        let mut value = minimal_environment_json();
1007        value.as_object_mut().unwrap().remove("scene_path");
1008        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1009        assert!(err.to_string().contains("scene_path"), "{err}");
1010    }
1011
1012    #[test]
1013    fn rejects_environment_config_missing_dimensions() {
1014        let mut value = minimal_environment_json();
1015        value.as_object_mut().unwrap().remove("dimensions");
1016        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1017        assert!(err.to_string().contains("dimensions"), "{err}");
1018    }
1019
1020    #[test]
1021    fn rejects_malformed_floor_feature() {
1022        let mut value = minimal_environment_json();
1023        value["features"]["floor"] = serde_json::json!({
1024            "type": "flat",
1025            "material": "concrete",
1026            "friction": 0.8
1027        });
1028        let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1029        assert!(err.to_string().contains("collision"), "{err}");
1030    }
1031
1032    #[test]
1033    fn parses_full_basic_arena_environment_including_dimensions_and_features() {
1034        let config: EnvironmentConfig =
1035            serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
1036
1037        assert_eq!(
1038            config.scene_path,
1039            "res://packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/basic_arena.tscn"
1040        );
1041        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1042
1043        let floor = config.features.floor.expect("basic_arena should declare a floor");
1044        assert_eq!(floor.material, "concrete");
1045        assert!(floor.collision);
1046
1047        let walls = config.features.walls.expect("basic_arena should declare walls");
1048        assert_eq!(walls.count, 4);
1049
1050        let lighting = config.features.lighting.expect("basic_arena should declare lighting");
1051        assert!(lighting.directional.enabled);
1052        assert!(lighting.ambient.enabled);
1053
1054        let spawn_points = config
1055            .features
1056            .spawn_points
1057            .expect("basic_arena should declare spawn points");
1058        assert_eq!(spawn_points.count, 5);
1059        assert_eq!(spawn_points.positions.len(), 5);
1060
1061        let obstacles = config.features.obstacles.expect("basic_arena should declare obstacles");
1062        assert_eq!(obstacles.obstacle_type, "mixed");
1063
1064        let goals = config.features.goals.expect("basic_arena should declare goals");
1065        assert_eq!(goals.count, 0);
1066
1067        let npcs = config.features.npcs.expect("basic_arena should declare npcs");
1068        assert!(!npcs.enabled);
1069    }
1070
1071    #[test]
1072    fn home_outdoor_urban_environment_stays_generic_with_no_task_specific_image_objects() {
1073        let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1074            .expect("home_outdoor_urban environment.json should parse");
1075
1076        assert_eq!(config.id, "home_outdoor_urban");
1077
1078        assert!(
1079            config.image_objects().next().is_none(),
1080            "home_outdoor_urban should declare no image objects - those are task-specific, got: {:?}",
1081            config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1082        );
1083    }
1084
1085    #[test]
1086    fn every_bundled_environment_is_structural_only_except_the_tracked_moving_objects_arena_exception() {
1087        // Environments model ground/walls/lighting/obstacles, not task content - `cat_environment`
1088        // violated this by baking cat image billboards directly into `environment.json` (LAB-3628)
1089        // and was deleted for it. This test used to walk every environment bundled under
1090        // `templates/configs/simulation/environments` on disk so a future environment couldn't
1091        // reintroduce the same mistake without this test catching it.
1092        //
1093        // That directory was extracted to the external `mecha10-assets` repo and deleted from
1094        // here (mecha-industries/mecha10#1348), so this repo can no longer discover "every
1095        // bundled environment" by listing a local directory - `mecha10-assets` is the new
1096        // source of truth and isn't readable from this crate. This test is downgraded to
1097        // checking the known, hand-maintained fixtures above (`BASIC_ARENA_ENVIRONMENT_JSON`,
1098        // `HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON`) rather than silently dropped - it still catches
1099        // a regression on either of those two, but can no longer catch a *new* environment
1100        // added directly to `mecha10-assets` smuggling in task-specific image objects. That gap
1101        // is real and not covered by any test in this repo any more.
1102        //
1103        // `moving_objects_arena` is a known, tracked exception (see its own
1104        // `moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles` test
1105        // above and the sibling issue to migrate it off baked-in image objects) - it is
1106        // deliberately excluded here rather than silently allowed to multiply.
1107        let mut checked = Vec::new();
1108        for (name, fixture) in [
1109            ("basic_arena", BASIC_ARENA_ENVIRONMENT_JSON),
1110            ("home_outdoor_urban", HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON),
1111        ] {
1112            let config: EnvironmentConfig =
1113                serde_json::from_str(fixture).unwrap_or_else(|e| panic!("{name} should parse: {e}"));
1114            assert!(
1115                config.image_objects().next().is_none(),
1116                "{} should declare no image objects - environments are structural-only, task \
1117                 content belongs in task.json, got: {:?}",
1118                name,
1119                config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1120            );
1121            checked.push(name.to_string());
1122        }
1123
1124        assert!(
1125            checked.contains(&"basic_arena".to_string()) && checked.contains(&"home_outdoor_urban".to_string()),
1126            "expected to have actually checked the known structural-only environments, got: {checked:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn parses_full_home_outdoor_urban_environment_including_dimensions_and_features() {
1132        let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1133            .expect("home_outdoor_urban environment.json should parse");
1134
1135        assert_eq!(
1136            config.scene_path,
1137            "res://packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/home_outdoor_urban.tscn"
1138        );
1139        assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1140
1141        // Bigger than basic_arena (20x20) - this environment needs room for
1142        // a partitioned home interior *and* a separate outdoor urban street section.
1143        assert!(config.dimensions.width > 20.0);
1144        assert!(config.dimensions.length > 0.0);
1145
1146        let floor = config
1147            .features
1148            .floor
1149            .expect("home_outdoor_urban should declare a floor");
1150        assert!(floor.collision);
1151
1152        let walls = config.features.walls.expect("home_outdoor_urban should declare walls");
1153        assert_eq!(walls.count, 4);
1154
1155        let lighting = config
1156            .features
1157            .lighting
1158            .expect("home_outdoor_urban should declare lighting");
1159        assert!(lighting.directional.enabled);
1160        assert!(lighting.ambient.enabled);
1161
1162        let spawn_points = config
1163            .features
1164            .spawn_points
1165            .expect("home_outdoor_urban should declare spawn points");
1166        assert_eq!(spawn_points.count, spawn_points.positions.len() as u32);
1167        assert!(
1168            spawn_points.count >= 2,
1169            "home_outdoor_urban should offer spawn points in both the home and outdoor sections"
1170        );
1171
1172        let goals = config.features.goals.expect("home_outdoor_urban should declare goals");
1173        assert!(
1174            goals.count > 0,
1175            "a navigation-focused environment should declare goal zones"
1176        );
1177
1178        let npcs = config.features.npcs.expect("home_outdoor_urban should declare npcs");
1179        assert!(npcs.enabled, "home_outdoor_urban should model dynamic pedestrians");
1180
1181        // Dense obstacle layout (interior partition walls + furniture + street furniture) is the
1182        // whole point of this environment - planning/navigation needs more to avoid than the
1183        // open basic_arena provides.
1184        assert!(
1185            config.features.objects.len() >= 10,
1186            "home_outdoor_urban should be densely furnished for navigation testing, got {} objects",
1187            config.features.objects.len()
1188        );
1189        assert!(
1190            config
1191                .features
1192                .objects
1193                .iter()
1194                .all(|obj| matches!(obj, SceneObject::Cube(_))),
1195            "home_outdoor_urban's structural/furniture objects should all be cubes, not task-specific images"
1196        );
1197    }
1198
1199    #[test]
1200    fn validate_rejects_zero_scale() {
1201        let root = repo_root();
1202        let image = ImageObject {
1203            source: "configs/simulation/images/aiko.jpg".to_string(),
1204            position: [0.0, 0.0, 0.0],
1205            rotation: [0.0, 0.0, 0.0],
1206            scale: [0.0, 1.0, 1.0],
1207            interactive: false,
1208            visible: true,
1209            motion: None,
1210        };
1211        let err = image.validate(&root).expect_err("zero scale should fail validation");
1212        assert!(err.to_string().contains("scale"));
1213    }
1214}