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