FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
lib.rs
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4//! # Tauri Application Library Entry Point
5//!
6//! This module provides the main entry point for the FERS Tauri desktop application.
7//! It bridges the Rust/Tauri frontend with the C++ `libfers` simulation engine via
8//! a Foreign Function Interface (FFI).
9//!
10//! ## Architecture Overview
11//!
12//! The application follows a three-layer architecture:
13//!
14//! 1. **Frontend (TypeScript/React)**: Provides the user interface for scenario
15//! editing and visualization.
16//! 2. **Middle Layer (Rust/Tauri)**: This module. Exposes Tauri commands that the
17//! frontend can invoke, and manages the simulation state via a thread-safe wrapper.
18//! 3. **Backend (C++ via FFI)**: The `libfers` library that performs the actual
19//! simulation computations, parsing, and serialization.
20//!
21//! ## Thread Safety
22//!
23//! The `FersContext` (which wraps the C++ simulation state) is protected by a `Mutex`
24//! and stored in Tauri's managed state. This ensures that concurrent calls from the
25//! UI are serialized, preventing data races on the non-thread-safe C++ object.
26//!
27//! ## Tauri Commands
28//!
29//! All functions annotated with `#[tauri::command]` are exposed to the frontend via
30//! Tauri's IPC mechanism. They can be invoked asynchronously from JavaScript/TypeScript.
31
32mod fers_api;
33
34use std::{fs, path::PathBuf, sync::Mutex};
35use tauri::{AppHandle, Emitter, Manager, State};
36
37/// Data structure for a single motion waypoint received from the UI.
38///
39/// Coordinates should be in the scenario's define frame (e.g. ENU).
40#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
41pub struct MotionWaypoint {
42 /// Time in seconds.
43 time: f64,
44 /// Easting/X coordinate in meters.
45 x: f64,
46 /// Northing/Y coordinate in meters.
47 y: f64,
48 /// Altitude/Z coordinate in meters (MSL).
49 altitude: f64,
50}
51
52/// Enum for the interpolation type received from the UI.
53#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
54#[serde(rename_all = "lowercase")]
55pub enum InterpolationType {
56 Static,
57 Linear,
58 Cubic,
59}
60
61/// Enum for the rotation angle unit received from the UI.
62#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug, Clone, Copy)]
63#[serde(rename_all = "lowercase")]
64pub enum RotationAngleUnit {
65 Deg,
66 Rad,
67}
68
69/// Data structure for a single interpolated point sent back to the UI.
70///
71/// Represents the physical state of a platform at a specific time step.
72#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
73pub struct InterpolatedMotionPoint {
74 /// X position in meters.
75 x: f64,
76 /// Y position in meters.
77 y: f64,
78 /// Z position in meters.
79 z: f64,
80 /// X velocity in m/s.
81 vx: f64,
82 /// Y velocity in m/s.
83 vy: f64,
84 /// Z velocity in m/s.
85 vz: f64,
86}
87
88/// Data structure for a single rotation waypoint received from the UI.
89#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
90pub struct RotationWaypoint {
91 /// Time in seconds.
92 time: f64,
93 /// Azimuth in compass units (0=North, pi/2 or 90=East).
94 azimuth: f64,
95 /// Elevation in the selected external units (positive up).
96 elevation: f64,
97}
98
99/// Data structure for a single interpolated rotation point sent back to the UI.
100#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
101pub struct InterpolatedRotationPoint {
102 /// Azimuth in compass units.
103 azimuth: f64,
104 /// Elevation in the selected external units.
105 elevation: f64,
106}
107
108/// Type alias for the managed Tauri state that holds the simulation context.
109///
110/// The `FersContext` is wrapped in a `Mutex` to ensure thread-safe access, as Tauri
111/// may invoke commands from multiple threads concurrently. This alias simplifies
112/// the function signatures of Tauri commands.
113type FersState = Mutex<fers_api::FersContext>;
114
115// --- Tauri Commands ---
116
117/// Sets the output directory for simulation results.
118#[tauri::command]
119fn set_output_directory(dir: String, state: State<'_, FersState>) -> Result<(), String> {
120 state.lock().map_err(|e| e.to_string())?.set_output_directory(&dir)
121}
122
123/// Returns the current FERS logger level.
124#[tauri::command]
125fn get_log_level() -> fers_api::LogLevel {
126 fers_api::get_log_level()
127}
128
129/// Sets the current FERS logger level.
130#[tauri::command]
131fn set_log_level(level: fers_api::LogLevel) -> Result<(), String> {
132 fers_api::set_log_level(level)
133}
134
135/// Loads a FERS scenario from an XML file into the simulation context.
136///
137/// This command replaces any existing in-memory scenario with the one parsed from
138/// the specified file. The file path is provided by the user via the frontend dialog.
139///
140/// # Parameters
141///
142/// * `filepath` - The absolute or relative path to the FERS XML scenario file.
143/// * `state` - Tauri-managed state containing the shared `FersContext`.
144///
145/// # Returns
146///
147/// * `Ok(Vec<String>)` containing any deduplicated non-fatal warnings detected while loading.
148/// * `Err(String)` containing an error message if loading failed (e.g., file not found,
149/// invalid XML, or a Mutex lock error).
150///
151/// # Example (from frontend)
152///
153/// ```typescript
154/// import { invoke } from '@tauri-apps/api/core';
155/// await invoke('load_scenario_from_xml_file', { filepath: '/path/to/scenario.xml' });
156/// ```
157#[tauri::command]
158fn load_scenario_from_xml_file(
159 filepath: String,
160 state: State<'_, FersState>,
161) -> Result<Vec<String>, String> {
162 state.lock().map_err(|e| e.to_string())?.load_scenario_from_xml_file(&filepath)
163}
164
165/// Retrieves the current in-memory scenario as a JSON string.
166///
167/// This command serializes the simulation state into JSON format, allowing the
168/// frontend to display and edit the scenario. The JSON structure mirrors the
169/// internal representation used by `libfers`.
170///
171/// # Parameters
172///
173/// * `state` - Tauri-managed state containing the shared `FersContext`.
174///
175/// # Returns
176///
177/// * `Ok(String)` containing the JSON representation of the scenario.
178/// * `Err(String)` containing an error message if serialization failed or if the
179/// Mutex could not be locked.
180///
181/// # Example (from frontend)
182///
183/// ```typescript
184/// import { invoke } from '@tauri-apps/api/core';
185/// const scenarioJson = await invoke<string>('get_scenario_as_json');
186/// const scenario = JSON.parse(scenarioJson);
187/// ```
188#[tauri::command]
189fn get_scenario_as_json(state: State<'_, FersState>) -> Result<String, String> {
190 state.lock().map_err(|e| e.to_string())?.get_scenario_as_json()
191}
192
193/// Retrieves the current in-memory scenario as a FERS XML string.
194///
195/// This command is typically used when the user wants to export the scenario
196/// (potentially modified in the UI) back to the standard FERS XML format for
197/// sharing or archival.
198///
199/// # Parameters
200///
201/// * `state` - Tauri-managed state containing the shared `FersContext`.
202///
203/// # Returns
204///
205/// * `Ok(String)` containing the XML representation of the scenario.
206/// * `Err(String)` containing an error message if serialization failed or if the
207/// Mutex could not be locked.
208///
209/// # Example (from frontend)
210///
211/// ```typescript
212/// import { invoke } from '@tauri-apps/api/core';
213/// const scenarioXml = await invoke<string>('get_scenario_as_xml');
214/// // Save scenarioXml to a file using Tauri's fs plugin
215/// ```
216#[tauri::command]
217fn get_scenario_as_xml(state: State<'_, FersState>) -> Result<String, String> {
218 state.lock().map_err(|e| e.to_string())?.get_scenario_as_xml()
219}
220
221/// Updates the in-memory scenario from a JSON string provided by the frontend.
222///
223/// This is the primary method for applying changes made in the UI back to the
224/// simulation engine. The JSON is deserialized and used to rebuild the internal
225/// C++ world representation.
226///
227/// # Parameters
228///
229/// * `json` - A JSON string representing the modified scenario structure.
230/// * `state` - Tauri-managed state containing the shared `FersContext`.
231///
232/// # Returns
233///
234/// * `Ok(Vec<String>)` containing any deduplicated non-fatal warnings detected while updating.
235/// * `Err(String)` containing an error message if deserialization failed, the JSON
236/// structure was invalid, or the Mutex could not be locked.
237///
238/// # Example (from frontend)
239///
240/// ```typescript
241/// import { invoke } from '@tauri-apps/api/core';
242/// const updatedScenario = { /* modified scenario object */ };
243/// await invoke('update_scenario_from_json', { json: JSON.stringify(updatedScenario) });
244/// ```
245#[tauri::command]
246fn update_scenario_from_json(
247 json: String,
248 state: State<'_, FersState>,
249) -> Result<Vec<String>, String> {
250 state.lock().map_err(|e| e.to_string())?.update_scenario_from_json(&json)
251}
252
253/// Triggers the simulation based on the current in-memory scenario.
254///
255/// This command immediately returns `Ok(())` and spawns a background thread to
256/// perform the actual computationally intensive simulation. This prevents the UI
257/// from freezing. The result of the simulation (success or failure) is
258/// communicated back to the frontend via Tauri events.
259///
260/// # Parameters
261///
262/// * `app_handle` - The Tauri application handle, used to access managed state
263/// and emit events.
264///
265/// # Events Emitted
266///
267/// * `simulation-output-metadata` - Emitted with metadata JSON before completion.
268/// * `simulation-complete` - Emitted with `()` as payload on successful completion.
269/// * `simulation-error` - Emitted with a `String` error message on failure.
270/// * `simulation-progress` - Emitted periodically with `{ message: String, current: i32, total: i32 }`.
271#[tauri::command]
272fn run_simulation(app_handle: AppHandle) -> Result<(), String> {
273 // Clone the AppHandle so we can move it into the background thread.
274 let app_handle_clone = app_handle.clone();
275
276 // Spawn a new thread to run the blocking C++ simulation.
277 std::thread::spawn(move || {
278 // Retrieve the managed state within the new thread.
279 let fers_state: State<'_, FersState> = app_handle_clone.state();
280 let result = fers_state
281 .lock()
282 .map_err(|e| e.to_string())
283 .and_then(|context| context.run_simulation(&app_handle_clone));
284
285 // Emit an event to the frontend based on the simulation result.
286 match result {
287 Ok(metadata_json) => {
288 app_handle_clone
289 .emit("simulation-output-metadata", metadata_json)
290 .expect("Failed to emit simulation-output-metadata event");
291 app_handle_clone
292 .emit("simulation-complete", ())
293 .expect("Failed to emit simulation-complete event");
294 }
295 Err(e) => {
296 app_handle_clone
297 .emit("simulation-error", e)
298 .expect("Failed to emit simulation-error event");
299 }
300 }
301 });
302
303 // Return immediately, allowing the UI to remain responsive.
304 Ok(())
305}
306
307fn sanitize_file_stem(name: &str) -> String {
308 let sanitized: String =
309 name.chars().map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }).collect();
310 if sanitized.is_empty() {
311 "simulation".to_string()
312 } else {
313 sanitized
314 }
315}
316
317/// Writes the most recent simulation output metadata JSON next to the generated HDF5 files.
318#[tauri::command]
319fn export_output_metadata_json(state: State<'_, FersState>) -> Result<String, String> {
320 let metadata_json = state.lock().map_err(|e| e.to_string())?.get_last_output_metadata_json()?;
321 let metadata: serde_json::Value = serde_json::from_str(&metadata_json)
322 .map_err(|e| format!("Failed to parse output metadata JSON: {}", e))?;
323
324 let simulation_name =
325 metadata.get("simulation_name").and_then(serde_json::Value::as_str).unwrap_or("simulation");
326 let output_directory = metadata
327 .get("output_directory")
328 .and_then(serde_json::Value::as_str)
329 .filter(|value| !value.is_empty())
330 .unwrap_or(".");
331
332 let mut output_path = PathBuf::from(output_directory);
333 fs::create_dir_all(&output_path)
334 .map_err(|e| format!("Failed to create metadata output directory: {}", e))?;
335 output_path.push(format!("{}_metadata.json", sanitize_file_stem(simulation_name)));
336
337 fs::write(&output_path, metadata_json)
338 .map_err(|e| format!("Failed to write metadata JSON: {}", e))?;
339
340 Ok(output_path.to_string_lossy().to_string())
341}
342
343/// Generates a KML visualization file for the current in-memory scenario.
344///
345/// This command spawns a background thread to handle file I/O and KML generation,
346/// preventing the UI from freezing. The result is communicated via events.
347///
348/// # Parameters
349///
350/// * `output_path` - The absolute file path where the KML file should be saved.
351/// * `app_handle` - The Tauri application handle.
352///
353/// # Events Emitted
354///
355/// * `kml-generation-complete` - Emitted with the output path `String` on success.
356/// * `kml-generation-error` - Emitted with a `String` error message on failure.
357#[tauri::command]
358fn generate_kml(output_path: String, app_handle: AppHandle) -> Result<(), String> {
359 let app_handle_clone = app_handle.clone();
360 std::thread::spawn(move || {
361 let fers_state: State<'_, FersState> = app_handle_clone.state();
362 let result = fers_state
363 .lock()
364 .map_err(|e| e.to_string())
365 .and_then(|context| context.generate_kml(&output_path));
366
367 match result {
368 Ok(_) => {
369 app_handle_clone
370 .emit("kml-generation-complete", &output_path)
371 .expect("Failed to emit kml-generation-complete event");
372 }
373 Err(e) => {
374 app_handle_clone
375 .emit("kml-generation-error", e)
376 .expect("Failed to emit kml-generation-error event");
377 }
378 }
379 });
380 Ok(())
381}
382
383/// A stateless command to calculate an interpolated motion path.
384///
385/// This command delegates to the `libfers` core to calculate a path from a given
386/// set of waypoints, ensuring the UI visualization is identical to the path
387/// the simulation would use.
388///
389/// # Parameters
390/// * `waypoints` - A vector of motion waypoints.
391/// * `interp_type` - The interpolation algorithm to use ('static', 'linear', 'cubic').
392/// * `num_points` - The desired number of points for the final path.
393///
394/// # Returns
395/// * `Ok(Vec<InterpolatedPoint>)` - The calculated path points.
396/// * `Err(String)` - An error message if the path calculation failed.
397#[tauri::command]
398fn get_interpolated_motion_path(
399 waypoints: Vec<MotionWaypoint>,
400 interp_type: InterpolationType,
401 num_points: usize,
402) -> Result<Vec<InterpolatedMotionPoint>, String> {
403 fers_api::get_interpolated_motion_path(waypoints, interp_type, num_points)
404}
405
406/// A stateless command to calculate an interpolated rotation path.
407///
408/// This command delegates to the `libfers` core to calculate a rotation path from a given
409/// set of waypoints. It is used by the UI to preview how the simulation will interpolate
410/// orientation changes over time.
411///
412/// # Parameters
413/// * `waypoints` - A vector of rotation waypoints in the selected external angle unit.
414/// * `interp_type` - The interpolation algorithm to use ('static', 'linear', 'cubic').
415/// * `angle_unit` - The angle unit to use for both input and output.
416/// * `num_points` - The desired number of points for the final path.
417///
418/// # Returns
419/// * `Ok(Vec<InterpolatedRotationPoint>)` - The calculated rotation points.
420/// * `Err(String)` - An error message if the calculation failed.
421#[tauri::command]
422fn get_interpolated_rotation_path(
423 waypoints: Vec<RotationWaypoint>,
424 interp_type: InterpolationType,
425 angle_unit: RotationAngleUnit,
426 num_points: usize,
427) -> Result<Vec<InterpolatedRotationPoint>, String> {
428 fers_api::get_interpolated_rotation_path(waypoints, interp_type, angle_unit, num_points)
429}
430
431/// Retrieves a 2D antenna gain pattern for visualization.
432///
433/// This command samples the antenna model loaded in the current simulation context.
434/// It is stateful because it relies on the antenna assets defined in the loaded scenario.
435///
436/// # Parameters
437/// * `antenna_id` - The unique ID of the antenna asset to sample.
438/// * `az_samples` - Resolution along the azimuth axis (e.g., 360).
439/// * `el_samples` - Resolution along the elevation axis (e.g., 180).
440/// * `frequency` - The frequency in Hz at which to calculate gain (relevant for frequency-dependent antennas).
441/// * `state` - The shared simulation state.
442///
443/// # Returns
444/// * `Ok(AntennaPatternData)` - Struct containing flattened gain array and dimensions.
445/// * `Err(String)` - Error if antenna not found or context locked.
446#[tauri::command]
447fn get_antenna_pattern(
448 antenna_id: String,
449 az_samples: usize,
450 el_samples: usize,
451 frequency: f64,
452 state: State<'_, FersState>,
453) -> Result<fers_api::AntennaPatternData, String> {
454 match state.try_lock() {
455 Ok(context) => context.get_antenna_pattern(&antenna_id, az_samples, el_samples, frequency),
456 Err(_) => Err("Backend is busy with another operation".to_string()),
457 }
458}
459
460/// Calculates visual radio links between platforms at a specific time.
461///
462/// This command performs a lightweight geometric and physics check to determine
463/// which platforms can "see" each other. It distinguishes between monostatic,
464/// bistatic, and interference paths based on signal-to-noise ratios.
465///
466/// # Parameters
467/// * `time` - The simulation time in seconds to evaluate.
468/// * `state` - The shared simulation state containing platforms and physics models.
469///
470/// # Returns
471/// * `Ok(Vec<VisualLink>)` - A list of renderable link segments with metadata (type, quality, label).
472/// * `Err(String)` - Error if context access fails.
473#[tauri::command]
474fn get_preview_links(
475 time: f64,
476 state: State<'_, FersState>,
477) -> Result<Vec<fers_api::VisualLink>, String> {
478 match state.try_lock() {
479 Ok(context) => context.calculate_preview_links(time),
480 Err(_) => Ok(vec![]), // backend is busy (simulation/KML running); return empty and retry next frame
481 }
482}
483
484/// Performs a granular state update on a specific simulation item via JSON.
485#[tauri::command]
486fn update_item_from_json(
487 item_type: String,
488 item_id: String,
489 json: String,
490 state: State<'_, FersState>,
491) -> Result<Vec<String>, String> {
492 let context = state.lock().map_err(|e| e.to_string())?;
493 match item_type.as_str() {
494 "Platform" => context.update_platform_from_json(&item_id, &json),
495 "Transmitter" => context.update_transmitter_from_json(&item_id, &json).map(|()| vec![]),
496 "Receiver" => context.update_receiver_from_json(&item_id, &json).map(|()| vec![]),
497 "Target" => context.update_target_from_json(&item_id, &json).map(|()| vec![]),
498 "Monostatic" => context.update_monostatic_from_json(&json).map(|()| vec![]),
499 "Antenna" => context.update_antenna_from_json(&json).map(|()| vec![]),
500 "Waveform" => context.update_waveform_from_json(&json).map(|()| vec![]),
501 "Timing" => context.update_timing_from_json(&item_id, &json).map(|()| vec![]),
502 "GlobalParameters" => context.update_parameters_from_json(&json),
503 _ => Ok(vec![]),
504 }
505}
506
507/// Initializes and runs the Tauri application.
508///
509/// This function is the main entry point for the desktop application. It performs
510/// the following setup steps:
511///
512/// 1. Creates a new `FersContext` by calling the FFI layer. If this fails, it
513/// indicates a linking or initialization problem with `libfers`.
514/// 2. Registers Tauri plugins for file dialogs, file system access, and shell operations.
515/// 3. Stores the `FersContext` in Tauri's managed state, protected by a `Mutex`.
516/// 4. Registers all Tauri commands so they can be invoked from the frontend.
517/// 5. Launches the Tauri application event loop.
518///
519/// # Panics
520///
521/// This function will panic if:
522/// * The `FersContext` cannot be created (indicating a problem with `libfers`).
523/// * The Tauri application fails to start due to misconfiguration.
524///
525/// # Example
526///
527/// This function is typically called from `main.rs`:
528///
529/// ```ignore
530/// fers_ui_lib::run();
531/// ```
532#[cfg_attr(mobile, tauri::mobile_entry_point)]
533pub fn run() {
534 // Attempt to create the FFI context. This validates that libfers is correctly linked.
535 let context = fers_api::FersContext::new()
536 .expect("Failed to create FERS context. Is libfers linked correctly?");
537
538 tauri::Builder::default()
539 // Register Tauri plugins for UI functionality
540 .plugin(tauri_plugin_dialog::init())
541 .plugin(tauri_plugin_opener::init())
542 .plugin(tauri_plugin_fs::init())
543 .setup(|app| {
544 fers_api::register_log_callback(app.handle().clone());
545 Ok(())
546 })
547 // Store the FersContext as managed state, accessible from all commands
548 .manage(Mutex::new(context))
549 // Register all Tauri commands that can be invoked from the frontend
550 .invoke_handler(tauri::generate_handler![
551 load_scenario_from_xml_file,
552 get_scenario_as_json,
553 get_scenario_as_xml,
554 update_scenario_from_json,
555 run_simulation,
556 export_output_metadata_json,
557 generate_kml,
558 get_interpolated_motion_path,
559 get_interpolated_rotation_path,
560 get_antenna_pattern,
561 get_preview_links,
562 update_item_from_json,
563 set_output_directory,
564 get_log_level,
565 set_log_level,
566 ])
567 .run(tauri::generate_context!())
568 .expect("error while running tauri application");
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use serde_json::json;
575
576 #[test]
577 fn test_interpolation_type_serialization() {
578 // Assert serialization outputs match the TypeScript string literals exactly
579 assert_eq!(serde_json::to_string(&InterpolationType::Static).unwrap(), "\"static\"");
580 assert_eq!(serde_json::to_string(&InterpolationType::Linear).unwrap(), "\"linear\"");
581 assert_eq!(serde_json::to_string(&InterpolationType::Cubic).unwrap(), "\"cubic\"");
582
583 // Ensure deserialization from UI payloads works
584 let parsed: InterpolationType = serde_json::from_str("\"linear\"").unwrap();
585 assert!(matches!(parsed, InterpolationType::Linear));
586 }
587
588 #[test]
589 fn test_motion_waypoint_serialization() {
590 let wp = MotionWaypoint { time: 1.0, x: 2.0, y: 3.0, altitude: 4.0 };
591 let serialized = serde_json::to_string(&wp).unwrap();
592
593 // Deserialize to generic JSON value to inspect structure
594 let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
595 assert_eq!(parsed["time"], 1.0);
596 assert_eq!(parsed["x"], 2.0);
597 assert_eq!(parsed["y"], 3.0);
598 assert_eq!(parsed["altitude"], 4.0);
599 }
600
601 #[test]
602 fn test_rotation_waypoint_serialization() {
603 let payload = json!({
604 "time": 5.5,
605 "azimuth": 180.0,
606 "elevation": -15.0
607 });
608
609 let wp: RotationWaypoint = serde_json::from_value(payload).unwrap();
610 assert_eq!(wp.time, 5.5);
611 assert_eq!(wp.azimuth, 180.0);
612 assert_eq!(wp.elevation, -15.0);
613 }
614
615 #[test]
616 fn test_fers_state_wrapper() {
617 // Verify that the C++ library correctly linked and can be protected by a Rust Mutex.
618 // This mimics how Tauri maintains the global state internally.
619 let context = fers_api::FersContext::new()
620 .expect("Failed to create FERS context. Check build.rs linking.");
621
622 let state: FersState = Mutex::new(context);
623
624 // Test safe access and locking capabilities.
625 let locked_context = state.lock().unwrap();
626
627 // Executing a read against the state
628 let result = locked_context.get_scenario_as_json();
629
630 // Since it's a completely uninitialized new context with no XML/JSON loaded yet,
631 // it correctly delegates to the backend and evaluates safely without segfaults.
632 assert!(result.is_ok() || result.is_err());
633 }
634}