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_cube_object_with_wood_material_type() {
668 let json = r#"{
673 "type": "cube",
674 "position": [1.0, 0.5, 2.0],
675 "size": [1.0, 1.0, 1.0],
676 "add_collision": false,
677 "material_type": "wood"
678 }"#;
679 let obj: SceneObject = serde_json::from_str(json).unwrap();
680 match obj {
681 SceneObject::Cube(cube) => {
682 assert_eq!(cube.material_type, Some("wood".to_string()));
683 }
684 SceneObject::Image(_) => panic!("expected cube"),
685 }
686 }
687
688 #[test]
689 fn parses_image_object() {
690 let json = r#"{
691 "type": "image",
692 "source": "configs/simulation/images/aiko.jpg",
693 "position": [0.0, 0.5, 3.5],
694 "rotation": [0, 180, 0],
695 "scale": [2.5, 2.5, 2.5],
696 "interactive": false,
697 "visible": true
698 }"#;
699 let obj: SceneObject = serde_json::from_str(json).unwrap();
700 match obj {
701 SceneObject::Image(image) => {
702 assert_eq!(image.source, "configs/simulation/images/aiko.jpg");
703 assert_eq!(image.position, [0.0, 0.5, 3.5]);
704 assert_eq!(image.rotation, [0.0, 180.0, 0.0]);
705 assert_eq!(image.scale, [2.5, 2.5, 2.5]);
706 assert!(!image.interactive);
707 assert!(image.visible);
708 }
709 SceneObject::Cube(_) => panic!("expected image"),
710 }
711 }
712
713 #[test]
714 fn image_object_defaults() {
715 let json = r#"{
716 "type": "image",
717 "source": "assets/waypoint.png",
718 "position": [1.0, 1.0, 1.0]
719 }"#;
720 let obj: SceneObject = serde_json::from_str(json).unwrap();
721 match obj {
722 SceneObject::Image(image) => {
723 assert_eq!(image.rotation, [0.0, 0.0, 0.0]);
724 assert_eq!(image.scale, [1.0, 1.0, 1.0]);
725 assert!(!image.interactive);
726 assert!(image.visible);
727 }
728 SceneObject::Cube(_) => panic!("expected image"),
729 }
730 }
731
732 #[test]
733 fn basic_arena_environment_stays_generic_with_no_task_specific_image_objects() {
734 let config: EnvironmentConfig =
735 serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
736
737 assert_eq!(config.id, "basic_arena");
738
739 assert!(
740 config.image_objects().next().is_none(),
741 "basic_arena should declare no image objects - those are task-specific, got: {:?}",
742 config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
743 );
744 }
745
746 #[test]
747 fn find_target_task_references_its_cats_preset() {
748 let value: serde_json::Value =
749 serde_json::from_str(FIND_TARGET_TASK_JSON).expect("find_target/task.json should parse");
750
751 assert_eq!(
752 value["preset"], "cats",
753 "find_target should reference its 'cats' preset (LAB-3629) rather than authoring objects[] inline"
754 );
755 assert!(
756 value.get("objects").is_none(),
757 "find_target/task.json should no longer declare an inline objects[] key"
758 );
759 }
760
761 #[test]
762 fn default_task_references_its_default_preset() {
763 let value: serde_json::Value = serde_json::from_str(DEFAULT_TASK_JSON).expect("default/task.json should parse");
764
765 assert_eq!(
766 value["env_name"], "basic_arena",
767 "default task must declare which environment its 'default' preset resolves against"
768 );
769 assert_eq!(
770 value["preset"], "default",
771 "default task should reference its 'default' preset (LAB-3630) rather than authoring objects[] inline"
772 );
773 assert!(
774 value.get("objects").is_none(),
775 "default/task.json should not declare an inline objects[] key"
776 );
777 }
778
779 #[test]
780 fn find_target_cats_preset_declares_its_own_image_objects() {
781 let root = repo_root();
782 let value: serde_json::Value = serde_json::from_str(CATS_PRESET_JSON).expect("preset.json should parse");
783 let objects: Vec<SceneObject> =
784 serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
785
786 let images: Vec<&ImageObject> = objects
787 .iter()
788 .filter_map(|obj| match obj {
789 SceneObject::Image(image) => Some(image),
790 SceneObject::Cube(_) => None,
791 })
792 .collect();
793
794 let aiko = images
795 .iter()
796 .find(|image| image.source.ends_with("aiko.jpg"))
797 .expect("cats preset should declare an aiko.jpg image object");
798 let resolved = aiko.validate(&root).expect("aiko image object should validate");
799 assert!(resolved.is_file());
800 assert_eq!(aiko.position.len(), 3);
801 assert_eq!(aiko.rotation.len(), 3);
802 assert_eq!(aiko.scale.len(), 3);
803
804 assert!(
805 images.iter().any(|image| image.source.ends_with("phoebe.jpg")),
806 "cats preset should also declare a phoebe.jpg image object"
807 );
808 }
809
810 #[test]
811 fn parses_image_object_with_motion_block() {
812 let json = r#"{
813 "type": "image",
814 "source": "configs/simulation/images/aiko.jpg",
815 "position": [-3.0, 0.5, 0.0],
816 "motion": {
817 "type": "patrol",
818 "axis": "x",
819 "range_min": -8.0,
820 "range_max": 8.0,
821 "speed": 1.0
822 }
823 }"#;
824 let obj: SceneObject = serde_json::from_str(json).unwrap();
825 match obj {
826 SceneObject::Image(image) => {
827 let motion = image.motion.expect("motion block should round-trip");
828 assert_eq!(motion.motion_type, MotionType::Patrol);
829 assert_eq!(motion.axis, MotionAxis::X);
830 assert_eq!(motion.range_min, -8.0);
831 assert_eq!(motion.range_max, 8.0);
832 assert_eq!(motion.speed, 1.0);
833 }
834 SceneObject::Cube(_) => panic!("expected image"),
835 }
836 }
837
838 #[test]
839 fn image_object_motion_defaults_to_none_and_speed_defaults_to_one() {
840 let no_motion: SceneObject =
841 serde_json::from_str(r#"{"type": "image", "source": "assets/waypoint.png", "position": [1.0, 1.0, 1.0]}"#)
842 .unwrap();
843 match no_motion {
844 SceneObject::Image(image) => assert!(image.motion.is_none()),
845 SceneObject::Cube(_) => panic!("expected image"),
846 }
847
848 let motion_without_speed: SceneObject = serde_json::from_str(
849 r#"{
850 "type": "image",
851 "source": "assets/waypoint.png",
852 "position": [1.0, 1.0, 1.0],
853 "motion": {"type": "patrol", "axis": "y", "range_min": -2.0, "range_max": 2.0}
854 }"#,
855 )
856 .unwrap();
857 match motion_without_speed {
858 SceneObject::Image(image) => {
859 let motion = image.motion.expect("motion block should round-trip");
860 assert_eq!(motion.axis, MotionAxis::Y);
861 assert_eq!(motion.speed, 1.0, "speed should default to 1.0 when omitted");
862 }
863 SceneObject::Cube(_) => panic!("expected image"),
864 }
865 }
866
867 #[test]
868 fn find_target_patrol_task_references_its_objects_patrol_preset() {
869 let value: serde_json::Value =
870 serde_json::from_str(FIND_TARGET_PATROL_TASK_JSON).expect("find_target_patrol/task.json should parse");
871 assert_eq!(
872 value["env_name"], "basic_arena",
873 "find_target_patrol must reuse basic_arena"
874 );
875 assert_eq!(
876 value["preset"], "objects_patrol",
877 "find_target_patrol should reference its 'objects_patrol' preset (LAB-3629) rather than authoring objects[] inline"
878 );
879 assert!(
880 value.get("objects").is_none(),
881 "find_target_patrol/task.json should no longer declare an inline objects[] key"
882 );
883 }
884
885 #[test]
886 fn objects_patrol_preset_declares_patrol_motion_on_its_image_objects() {
887 let root = repo_root();
888 let value: serde_json::Value =
889 serde_json::from_str(OBJECTS_PATROL_PRESET_JSON).expect("preset.json should parse");
890
891 let objects: Vec<SceneObject> =
892 serde_json::from_value(value["objects"].clone()).expect("preset.json objects[] should parse");
893
894 let images: Vec<&ImageObject> = objects
895 .iter()
896 .filter_map(|obj| match obj {
897 SceneObject::Image(image) => Some(image),
898 SceneObject::Cube(_) => None,
899 })
900 .collect();
901
902 assert!(!images.is_empty(), "find_target_patrol should declare image objects");
903
904 for image in &images {
905 if image.source.ends_with("aiko.jpg") || image.source.ends_with("phoebe.jpg") {
915 let resolved = image
916 .validate(&root)
917 .expect("patrol image object with a locally-available asset should validate");
918 assert!(resolved.is_file());
919 }
920
921 let motion = image
922 .motion
923 .as_ref()
924 .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
925 assert_eq!(motion.motion_type, MotionType::Patrol);
926 assert_eq!(motion.axis, MotionAxis::X);
927 assert!(
928 motion.range_min < motion.range_max,
929 "range_min should be less than range_max for {}",
930 image.source
931 );
932 assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
935 }
936 }
937
938 #[test]
939 fn moving_objects_arena_declares_patrolling_image_objects_with_no_static_obstacles() {
940 let root = repo_root();
941 let config: EnvironmentConfig = serde_json::from_str(MOVING_OBJECTS_ARENA_ENVIRONMENT_JSON)
942 .expect("moving_objects_arena environment.json should parse");
943
944 assert_eq!(config.id, "moving_objects_arena");
945
946 let images: Vec<&ImageObject> = config.image_objects().collect();
947 assert!(
948 images.len() >= 2,
949 "moving_objects_arena should declare at least two moving image objects, got {}",
950 images.len()
951 );
952
953 for image in &images {
954 let resolved = image
955 .validate(&root)
956 .unwrap_or_else(|e| panic!("{} should validate: {e}", image.source));
957 assert!(resolved.is_file());
958
959 let motion = image
960 .motion
961 .as_ref()
962 .unwrap_or_else(|| panic!("{} should declare a motion block", image.source));
963 assert_eq!(motion.motion_type, MotionType::Patrol);
964 assert!(
965 motion.range_min < motion.range_max,
966 "range_min should be less than range_max for {}",
967 image.source
968 );
969 assert!(motion.range_min > -9.75 && motion.range_max < 9.75);
972 }
973
974 assert!(
975 config
976 .features
977 .obstacles
978 .as_ref()
979 .map_or(true, |obstacles| obstacles.count == 0),
980 "moving_objects_arena should have no static obstacles cluttering the moving-target test"
981 );
982 }
983
984 #[test]
985 fn validate_rejects_missing_source_file() {
986 let root = repo_root();
987 let image = ImageObject {
988 source: "configs/simulation/images/does_not_exist.jpg".to_string(),
989 position: [0.0, 0.0, 0.0],
990 rotation: [0.0, 0.0, 0.0],
991 scale: [1.0, 1.0, 1.0],
992 interactive: false,
993 visible: true,
994 motion: None,
995 };
996 let err = image
997 .validate(&root)
998 .expect_err("missing source file should fail validation");
999 assert!(err.to_string().contains("does not resolve to a file"));
1000 }
1001
1002 fn minimal_environment_json() -> serde_json::Value {
1004 serde_json::json!({
1005 "id": "basic_arena",
1006 "version": "1.0.0",
1007 "name": "Basic Arena",
1008 "description": "A test arena",
1009 "scene_path": "res://basic_arena.tscn",
1010 "dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
1011 "features": {}
1012 })
1013 }
1014
1015 #[test]
1016 fn parses_a_minimal_valid_environment_config() {
1017 let config: EnvironmentConfig = serde_json::from_value(minimal_environment_json()).unwrap();
1018 assert_eq!(config.scene_path, "res://basic_arena.tscn");
1019 assert_eq!(config.dimensions.width, 20.0);
1020 assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1021 assert!(config.features.floor.is_none());
1022 }
1023
1024 #[test]
1025 fn rejects_environment_config_missing_scene_path() {
1026 let mut value = minimal_environment_json();
1027 value.as_object_mut().unwrap().remove("scene_path");
1028 let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1029 assert!(err.to_string().contains("scene_path"), "{err}");
1030 }
1031
1032 #[test]
1033 fn rejects_environment_config_missing_dimensions() {
1034 let mut value = minimal_environment_json();
1035 value.as_object_mut().unwrap().remove("dimensions");
1036 let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1037 assert!(err.to_string().contains("dimensions"), "{err}");
1038 }
1039
1040 #[test]
1041 fn rejects_malformed_floor_feature() {
1042 let mut value = minimal_environment_json();
1043 value["features"]["floor"] = serde_json::json!({
1044 "type": "flat",
1045 "material": "concrete",
1046 "friction": 0.8
1047 });
1048 let err = serde_json::from_value::<EnvironmentConfig>(value).unwrap_err();
1049 assert!(err.to_string().contains("collision"), "{err}");
1050 }
1051
1052 #[test]
1053 fn parses_full_basic_arena_environment_including_dimensions_and_features() {
1054 let config: EnvironmentConfig =
1055 serde_json::from_str(BASIC_ARENA_ENVIRONMENT_JSON).expect("basic_arena environment.json should parse");
1056
1057 assert_eq!(
1058 config.scene_path,
1059 "res://packages/mecha10-templates/templates/configs/simulation/environments/basic_arena/basic_arena.tscn"
1060 );
1061 assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1062
1063 let floor = config.features.floor.expect("basic_arena should declare a floor");
1064 assert_eq!(floor.material, "concrete");
1065 assert!(floor.collision);
1066
1067 let walls = config.features.walls.expect("basic_arena should declare walls");
1068 assert_eq!(walls.count, 4);
1069
1070 let lighting = config.features.lighting.expect("basic_arena should declare lighting");
1071 assert!(lighting.directional.enabled);
1072 assert!(lighting.ambient.enabled);
1073
1074 let spawn_points = config
1075 .features
1076 .spawn_points
1077 .expect("basic_arena should declare spawn points");
1078 assert_eq!(spawn_points.count, 5);
1079 assert_eq!(spawn_points.positions.len(), 5);
1080
1081 let obstacles = config.features.obstacles.expect("basic_arena should declare obstacles");
1082 assert_eq!(obstacles.obstacle_type, "mixed");
1083
1084 let goals = config.features.goals.expect("basic_arena should declare goals");
1085 assert_eq!(goals.count, 0);
1086
1087 let npcs = config.features.npcs.expect("basic_arena should declare npcs");
1088 assert!(!npcs.enabled);
1089 }
1090
1091 #[test]
1092 fn home_outdoor_urban_environment_stays_generic_with_no_task_specific_image_objects() {
1093 let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1094 .expect("home_outdoor_urban environment.json should parse");
1095
1096 assert_eq!(config.id, "home_outdoor_urban");
1097
1098 assert!(
1099 config.image_objects().next().is_none(),
1100 "home_outdoor_urban should declare no image objects - those are task-specific, got: {:?}",
1101 config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1102 );
1103 }
1104
1105 #[test]
1106 fn every_bundled_environment_is_structural_only_except_the_tracked_moving_objects_arena_exception() {
1107 let mut checked = Vec::new();
1128 for (name, fixture) in [
1129 ("basic_arena", BASIC_ARENA_ENVIRONMENT_JSON),
1130 ("home_outdoor_urban", HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON),
1131 ] {
1132 let config: EnvironmentConfig =
1133 serde_json::from_str(fixture).unwrap_or_else(|e| panic!("{name} should parse: {e}"));
1134 assert!(
1135 config.image_objects().next().is_none(),
1136 "{} should declare no image objects - environments are structural-only, task \
1137 content belongs in task.json, got: {:?}",
1138 name,
1139 config.image_objects().map(|i| &i.source).collect::<Vec<_>>()
1140 );
1141 checked.push(name.to_string());
1142 }
1143
1144 assert!(
1145 checked.contains(&"basic_arena".to_string()) && checked.contains(&"home_outdoor_urban".to_string()),
1146 "expected to have actually checked the known structural-only environments, got: {checked:?}"
1147 );
1148 }
1149
1150 #[test]
1151 fn parses_full_home_outdoor_urban_environment_including_dimensions_and_features() {
1152 let config: EnvironmentConfig = serde_json::from_str(HOME_OUTDOOR_URBAN_ENVIRONMENT_JSON)
1153 .expect("home_outdoor_urban environment.json should parse");
1154
1155 assert_eq!(
1156 config.scene_path,
1157 "res://packages/mecha10-templates/templates/configs/simulation/environments/home_outdoor_urban/home_outdoor_urban.tscn"
1158 );
1159 assert_eq!(config.dimensions.unit, DistanceUnit::Meters);
1160
1161 assert!(config.dimensions.width > 20.0);
1164 assert!(config.dimensions.length > 0.0);
1165
1166 let floor = config
1167 .features
1168 .floor
1169 .expect("home_outdoor_urban should declare a floor");
1170 assert!(floor.collision);
1171
1172 let walls = config.features.walls.expect("home_outdoor_urban should declare walls");
1173 assert_eq!(walls.count, 4);
1174
1175 let lighting = config
1176 .features
1177 .lighting
1178 .expect("home_outdoor_urban should declare lighting");
1179 assert!(lighting.directional.enabled);
1180 assert!(lighting.ambient.enabled);
1181
1182 let spawn_points = config
1183 .features
1184 .spawn_points
1185 .expect("home_outdoor_urban should declare spawn points");
1186 assert_eq!(spawn_points.count, spawn_points.positions.len() as u32);
1187 assert!(
1188 spawn_points.count >= 2,
1189 "home_outdoor_urban should offer spawn points in both the home and outdoor sections"
1190 );
1191
1192 let goals = config.features.goals.expect("home_outdoor_urban should declare goals");
1193 assert!(
1194 goals.count > 0,
1195 "a navigation-focused environment should declare goal zones"
1196 );
1197
1198 let npcs = config.features.npcs.expect("home_outdoor_urban should declare npcs");
1199 assert!(npcs.enabled, "home_outdoor_urban should model dynamic pedestrians");
1200
1201 assert!(
1205 config.features.objects.len() >= 10,
1206 "home_outdoor_urban should be densely furnished for navigation testing, got {} objects",
1207 config.features.objects.len()
1208 );
1209 assert!(
1210 config
1211 .features
1212 .objects
1213 .iter()
1214 .all(|obj| matches!(obj, SceneObject::Cube(_))),
1215 "home_outdoor_urban's structural/furniture objects should all be cubes, not task-specific images"
1216 );
1217 }
1218
1219 #[test]
1220 fn validate_rejects_zero_scale() {
1221 let root = repo_root();
1222 let image = ImageObject {
1223 source: "configs/simulation/images/aiko.jpg".to_string(),
1224 position: [0.0, 0.0, 0.0],
1225 rotation: [0.0, 0.0, 0.0],
1226 scale: [0.0, 1.0, 1.0],
1227 interactive: false,
1228 visible: true,
1229 motion: None,
1230 };
1231 let err = image.validate(&root).expect_err("zero scale should fail validation");
1232 assert!(err.to_string().contains("scale"));
1233 }
1234}