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