mecha10_cli/types/
simulation.rs

1//! Simulation configuration types
2//!
3//! Provides environment-aware configuration for simulation with CLI override support.
4//! These types represent runtime simulation configuration loaded from configs/{profile}/simulation/.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Godot engine configuration
10#[derive(Debug, Clone, Deserialize, Serialize)]
11#[allow(dead_code)] // Part of public API, may be used by library consumers
12pub struct GodotConfig {
13    /// Path to Godot executable
14    #[serde(default = "default_godot_path")]
15    pub executable_path: String,
16
17    /// Run in headless mode (no GUI)
18    #[serde(default)]
19    pub headless: bool,
20
21    /// Viewport width
22    #[serde(default = "default_viewport_width")]
23    pub viewport_width: u32,
24
25    /// Viewport height
26    #[serde(default = "default_viewport_height")]
27    pub viewport_height: u32,
28}
29
30fn default_godot_path() -> String {
31    "godot".to_string()
32}
33
34fn default_viewport_width() -> u32 {
35    1280
36}
37
38fn default_viewport_height() -> u32 {
39    720
40}
41
42impl Default for GodotConfig {
43    fn default() -> Self {
44        Self {
45            executable_path: default_godot_path(),
46            headless: false,
47            viewport_width: default_viewport_width(),
48            viewport_height: default_viewport_height(),
49        }
50    }
51}
52
53/// Camera stream configuration
54#[derive(Debug, Clone, Deserialize, Serialize)]
55#[allow(dead_code)] // Part of public API, may be used by library consumers
56pub struct CameraConfig {
57    /// Enable camera streaming
58    #[serde(default = "default_camera_enabled")]
59    pub enabled: bool,
60
61    /// Camera resolution width
62    #[serde(default = "default_camera_width")]
63    pub width: u32,
64
65    /// Camera resolution height
66    #[serde(default = "default_camera_height")]
67    pub height: u32,
68
69    /// Frames per second
70    #[serde(default = "default_camera_fps")]
71    pub fps: u32,
72}
73
74fn default_camera_enabled() -> bool {
75    true
76}
77
78fn default_camera_width() -> u32 {
79    320
80}
81
82fn default_camera_height() -> u32 {
83    240
84}
85
86fn default_camera_fps() -> u32 {
87    20
88}
89
90impl Default for CameraConfig {
91    fn default() -> Self {
92        Self {
93            enabled: default_camera_enabled(),
94            width: default_camera_width(),
95            height: default_camera_height(),
96            fps: default_camera_fps(),
97        }
98    }
99}
100
101/// Network ports configuration
102#[derive(Debug, Clone, Deserialize, Serialize)]
103#[allow(dead_code)] // Part of public API, may be used by library consumers
104pub struct NetworkingConfig {
105    /// Godot protocol server port
106    #[serde(default = "default_protocol_port")]
107    pub protocol_port: u16,
108
109    /// Godot protocol server bind address
110    #[serde(default = "default_bind_address")]
111    pub protocol_bind: String,
112
113    /// Camera stream port
114    #[serde(default = "default_camera_port")]
115    pub camera_port: u16,
116
117    /// Camera stream bind address
118    #[serde(default = "default_bind_address")]
119    pub camera_bind: String,
120
121    /// WebRTC signaling port
122    #[serde(default = "default_signaling_port")]
123    pub signaling_port: u16,
124
125    /// WebRTC signaling bind address
126    #[serde(default = "default_bind_address")]
127    pub signaling_bind: String,
128}
129
130fn default_protocol_port() -> u16 {
131    11008
132}
133
134fn default_camera_port() -> u16 {
135    11009
136}
137
138fn default_signaling_port() -> u16 {
139    11010
140}
141
142fn default_bind_address() -> String {
143    "0.0.0.0".to_string()
144}
145
146impl Default for NetworkingConfig {
147    fn default() -> Self {
148        Self {
149            protocol_port: default_protocol_port(),
150            protocol_bind: default_bind_address(),
151            camera_port: default_camera_port(),
152            camera_bind: default_bind_address(),
153            signaling_port: default_signaling_port(),
154            signaling_bind: default_bind_address(),
155        }
156    }
157}
158
159/// Runtime simulation configuration
160///
161/// Loaded from configs/{profile}/simulation/config.json or {scenario}.json.
162/// This is distinct from ProjectSimulationConfig which is stored in mecha10.json.
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct SimulationConfig {
165    /// Robot model identifier (e.g., "@mecha10/simulation-models/rover")
166    pub model: String,
167
168    /// Path to model configuration file
169    pub model_config: Option<String>,
170
171    /// Environment identifier (e.g., "@mecha10/simulation-environments/basic_arena")
172    pub environment: String,
173
174    /// Path to environment configuration file
175    pub environment_config: Option<String>,
176
177    /// Godot engine configuration
178    #[serde(default)]
179    pub godot: GodotConfig,
180
181    /// Camera configuration
182    #[serde(default)]
183    pub camera: CameraConfig,
184
185    /// Networking configuration
186    #[serde(default)]
187    pub networking: NetworkingConfig,
188}
189
190impl SimulationConfig {
191    /// Load simulation config with environment and scenario support
192    ///
193    /// Priority (first found wins):
194    /// 1. configs/{profile}/simulation/{scenario}.json (if scenario specified)
195    /// 2. configs/{profile}/simulation/config.json
196    /// 3. configs/common/simulation/config.json
197    /// 4. Error
198    #[allow(dead_code)] // Part of public API, may be used by library consumers
199    pub fn load() -> anyhow::Result<Self> {
200        Self::load_with_profile_and_scenario(None, None)
201    }
202
203    /// Load simulation config with specific profile and/or scenario
204    ///
205    /// # Arguments
206    /// * `profile` - Config profile (dev, production, etc.). If None, uses MECHA10_ENVIRONMENT or "dev"
207    /// * `scenario` - Scenario name (navigation, obstacle_avoidance, etc.). If None, uses MECHA10_SIMULATION_SCENARIO or loads config.json
208    pub fn load_with_profile_and_scenario(profile: Option<&str>, scenario: Option<&str>) -> anyhow::Result<Self> {
209        use std::env;
210
211        // Get profile from parameter, env var, or default
212        let profile = profile
213            .map(String::from)
214            .or_else(|| env::var("MECHA10_CONFIG_PROFILE").ok())
215            .or_else(|| env::var("MECHA10_ENVIRONMENT").ok())
216            .unwrap_or_else(|| "dev".to_string());
217
218        // Get scenario from parameter or env var
219        let scenario = scenario
220            .map(String::from)
221            .or_else(|| env::var("MECHA10_SIMULATION_SCENARIO").ok());
222
223        // Try multiple base paths (for running from different locations)
224        let base_paths = vec![
225            PathBuf::from("."),           // Current directory
226            PathBuf::from("../.."),       // From target/debug/deps
227            PathBuf::from("../../../.."), // From packages/*/target/debug/deps
228        ];
229
230        // Build fallback chain for each base path
231        for base_path in &base_paths {
232            let configs_dir = base_path.join("configs");
233
234            // 1. Try scenario-specific config (if scenario specified)
235            if let Some(scenario_name) = &scenario {
236                let scenario_path = configs_dir
237                    .join(&profile)
238                    .join("simulation")
239                    .join(format!("{}.json", scenario_name));
240
241                if scenario_path.exists() {
242                    let content = std::fs::read_to_string(&scenario_path)?;
243                    let config: Self = serde_json::from_str(&content)?;
244                    return Ok(config);
245                }
246            }
247
248            // 2. Try profile-specific config.json
249            let profile_config_path = configs_dir.join(&profile).join("simulation").join("config.json");
250
251            if profile_config_path.exists() {
252                let content = std::fs::read_to_string(&profile_config_path)?;
253                let config: Self = serde_json::from_str(&content)?;
254                return Ok(config);
255            }
256
257            // 3. Try common config
258            let common_config_path = configs_dir.join("common").join("simulation").join("config.json");
259
260            if common_config_path.exists() {
261                let content = std::fs::read_to_string(&common_config_path)?;
262                let config: Self = serde_json::from_str(&content)?;
263                return Ok(config);
264            }
265        }
266
267        if let Some(scenario_name) = &scenario {
268            Err(anyhow::anyhow!(
269                "No simulation config found.\nTried:\n  - configs/{}/simulation/{}.json\n  - configs/{}/simulation/config.json\n  - configs/common/simulation/config.json",
270                profile,
271                scenario_name,
272                profile
273            ))
274        } else {
275            Err(anyhow::anyhow!(
276                "No simulation config found.\nTried:\n  - configs/{}/simulation/config.json\n  - configs/common/simulation/config.json",
277                profile
278            ))
279        }
280    }
281
282    /// Apply CLI overrides to config
283    ///
284    /// Allows command-line arguments to override config file values.
285    #[allow(dead_code)] // Part of public API, may be used by library consumers
286    pub fn with_overrides(mut self, overrides: SimulationOverrides) -> Self {
287        if let Some(model) = overrides.model {
288            self.model = model;
289        }
290        if let Some(model_config) = overrides.model_config {
291            self.model_config = Some(model_config);
292        }
293        if let Some(environment) = overrides.environment {
294            self.environment = environment;
295        }
296        if let Some(environment_config) = overrides.environment_config {
297            self.environment_config = Some(environment_config);
298        }
299        if let Some(headless) = overrides.headless {
300            self.godot.headless = headless;
301        }
302        self
303    }
304
305    /// Get the model path for Godot
306    ///
307    /// Resolves @mecha10 references to actual filesystem paths.
308    #[allow(dead_code)] // Part of public API, may be used by library consumers
309    pub fn resolve_model_path(&self, framework_path: &Path) -> PathBuf {
310        if self.model.starts_with("@mecha10/") {
311            let relative = self.model.strip_prefix("@mecha10/").unwrap();
312            framework_path.join("packages").join(relative)
313        } else {
314            PathBuf::from(&self.model)
315        }
316    }
317
318    /// Get the environment path for Godot
319    ///
320    /// Resolves @mecha10 references to actual filesystem paths.
321    #[allow(dead_code)] // Part of public API, may be used by library consumers
322    pub fn resolve_environment_path(&self, framework_path: &Path) -> PathBuf {
323        if self.environment.starts_with("@mecha10/") {
324            let relative = self.environment.strip_prefix("@mecha10/").unwrap();
325            framework_path.join("packages").join(relative)
326        } else {
327            PathBuf::from(&self.environment)
328        }
329    }
330}
331
332/// CLI overrides for simulation config
333#[derive(Debug, Default, Clone)]
334#[allow(dead_code)] // Part of public API, may be used by library consumers
335pub struct SimulationOverrides {
336    pub model: Option<String>,
337    pub model_config: Option<String>,
338    pub environment: Option<String>,
339    pub environment_config: Option<String>,
340    pub headless: Option<bool>,
341}