1use 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 #[serde(default)]
191 pub motion: Option<ObjectMotion>,
192}
193
194#[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 pub fn load(path: &Path) -> Result<Self> {
238 super::load_json(path, "environment config")
239 }
240
241 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 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 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 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 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 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 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 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 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 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}