1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4//! # Tauri Application Library Entry Point
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).
10//! ## Architecture Overview
12//! The application follows a three-layer architecture:
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.
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.
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.
34use std::{fs, path::PathBuf, sync::Mutex};
35use tauri::{AppHandle, Emitter, Manager, State};
37/// Data structure for a single motion waypoint received from the UI.
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 {
44 /// Easting/X coordinate in meters.
46 /// Northing/Y coordinate in meters.
48 /// Altitude/Z coordinate in meters (MSL).
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 {
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 {
69/// Data structure for a single interpolated point sent back to the UI.
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.
76 /// Y position in meters.
78 /// Z position in meters.
80 /// X velocity in m/s.
82 /// Y velocity in m/s.
84 /// Z velocity in m/s.
88/// Data structure for a single rotation waypoint received from the UI.
89#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
90pub struct RotationWaypoint {
93 /// Azimuth in compass units (0=North, pi/2 or 90=East).
95 /// Elevation in the selected external units (positive up).
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.
104 /// Elevation in the selected external units.
108/// Type alias for the managed Tauri state that holds the simulation context.
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>;
115// --- Tauri Commands ---
117/// Sets the output directory for simulation results.
119fn set_output_directory(dir: String, state: State<'_, FersState>) -> Result<(), String> {
120 state.lock().map_err(|e| e.to_string())?.set_output_directory(&dir)
123/// Returns the current FERS logger level.
125fn get_log_level() -> fers_api::LogLevel {
126 fers_api::get_log_level()
129/// Sets the current FERS logger level.
131fn set_log_level(level: fers_api::LogLevel) -> Result<(), String> {
132 fers_api::set_log_level(level)
135/// Loads a FERS scenario from an XML file into the simulation context.
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.
142/// * `filepath` - The absolute or relative path to the FERS XML scenario file.
143/// * `state` - Tauri-managed state containing the shared `FersContext`.
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).
151/// # Example (from frontend)
154/// import { invoke } from '@tauri-apps/api/core';
155/// await invoke('load_scenario_from_xml_file', { filepath: '/path/to/scenario.xml' });
158fn load_scenario_from_xml_file(
160 state: State<'_, FersState>,
161) -> Result<Vec<String>, String> {
162 state.lock().map_err(|e| e.to_string())?.load_scenario_from_xml_file(&filepath)
165/// Retrieves the current in-memory scenario as a JSON string.
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`.
173/// * `state` - Tauri-managed state containing the shared `FersContext`.
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.
181/// # Example (from frontend)
184/// import { invoke } from '@tauri-apps/api/core';
185/// const scenarioJson = await invoke<string>('get_scenario_as_json');
186/// const scenario = JSON.parse(scenarioJson);
189fn get_scenario_as_json(state: State<'_, FersState>) -> Result<String, String> {
190 state.lock().map_err(|e| e.to_string())?.get_scenario_as_json()
193/// Retrieves the current in-memory scenario as a FERS XML string.
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.
201/// * `state` - Tauri-managed state containing the shared `FersContext`.
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.
209/// # Example (from frontend)
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
217fn get_scenario_as_xml(state: State<'_, FersState>) -> Result<String, String> {
218 state.lock().map_err(|e| e.to_string())?.get_scenario_as_xml()
221/// Updates the in-memory scenario from a JSON string provided by the frontend.
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.
229/// * `json` - A JSON string representing the modified scenario structure.
230/// * `state` - Tauri-managed state containing the shared `FersContext`.
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.
238/// # Example (from frontend)
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) });
246fn update_scenario_from_json(
248 state: State<'_, FersState>,
249) -> Result<Vec<String>, String> {
250 state.lock().map_err(|e| e.to_string())?.update_scenario_from_json(&json)
253/// Triggers the simulation based on the current in-memory scenario.
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.
262/// * `app_handle` - The Tauri application handle, used to access managed state
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 }`.
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();
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
282 .map_err(|e| e.to_string())
283 .and_then(|context| context.run_simulation(&app_handle_clone));
285 // Emit an event to the frontend based on the simulation result.
287 Ok(metadata_json) => {
289 .emit("simulation-output-metadata", metadata_json)
290 .expect("Failed to emit simulation-output-metadata event");
292 .emit("simulation-complete", ())
293 .expect("Failed to emit simulation-complete event");
297 .emit("simulation-error", e)
298 .expect("Failed to emit simulation-error event");
303 // Return immediately, allowing the UI to remain responsive.
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()
317/// Writes the most recent simulation output metadata JSON next to the generated HDF5 files.
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))?;
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())
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)));
337 fs::write(&output_path, metadata_json)
338 .map_err(|e| format!("Failed to write metadata JSON: {}", e))?;
340 Ok(output_path.to_string_lossy().to_string())
343/// Generates a KML visualization file for the current in-memory scenario.
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.
350/// * `output_path` - The absolute file path where the KML file should be saved.
351/// * `app_handle` - The Tauri application handle.
355/// * `kml-generation-complete` - Emitted with the output path `String` on success.
356/// * `kml-generation-error` - Emitted with a `String` error message on failure.
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
364 .map_err(|e| e.to_string())
365 .and_then(|context| context.generate_kml(&output_path));
370 .emit("kml-generation-complete", &output_path)
371 .expect("Failed to emit kml-generation-complete event");
375 .emit("kml-generation-error", e)
376 .expect("Failed to emit kml-generation-error event");
383/// A stateless command to calculate an interpolated motion path.
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.
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.
395/// * `Ok(Vec<InterpolatedPoint>)` - The calculated path points.
396/// * `Err(String)` - An error message if the path calculation failed.
398fn get_interpolated_motion_path(
399 waypoints: Vec<MotionWaypoint>,
400 interp_type: InterpolationType,
402) -> Result<Vec<InterpolatedMotionPoint>, String> {
403 fers_api::get_interpolated_motion_path(waypoints, interp_type, num_points)
406/// A stateless command to calculate an interpolated rotation path.
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.
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.
419/// * `Ok(Vec<InterpolatedRotationPoint>)` - The calculated rotation points.
420/// * `Err(String)` - An error message if the calculation failed.
422fn get_interpolated_rotation_path(
423 waypoints: Vec<RotationWaypoint>,
424 interp_type: InterpolationType,
425 angle_unit: RotationAngleUnit,
427) -> Result<Vec<InterpolatedRotationPoint>, String> {
428 fers_api::get_interpolated_rotation_path(waypoints, interp_type, angle_unit, num_points)
431/// Retrieves a 2D antenna gain pattern for visualization.
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.
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.
444/// * `Ok(AntennaPatternData)` - Struct containing flattened gain array and dimensions.
445/// * `Err(String)` - Error if antenna not found or context locked.
447fn get_antenna_pattern(
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()),
460/// Calculates visual radio links between platforms at a specific time.
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.
467/// * `time` - The simulation time in seconds to evaluate.
468/// * `state` - The shared simulation state containing platforms and physics models.
471/// * `Ok(Vec<VisualLink>)` - A list of renderable link segments with metadata (type, quality, label).
472/// * `Err(String)` - Error if context access fails.
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
484/// Performs a granular state update on a specific simulation item via JSON.
486fn update_item_from_json(
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),
507/// Initializes and runs the Tauri application.
509/// This function is the main entry point for the desktop application. It performs
510/// the following setup steps:
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.
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.
527/// This function is typically called from `main.rs`:
530/// fers_ui_lib::run();
532#[cfg_attr(mobile, tauri::mobile_entry_point)]
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?");
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())
544 fers_api::register_log_callback(app.handle().clone());
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,
554 update_scenario_from_json,
556 export_output_metadata_json,
558 get_interpolated_motion_path,
559 get_interpolated_rotation_path,
562 update_item_from_json,
563 set_output_directory,
567 .run(tauri::generate_context!())
568 .expect("error while running tauri application");
574 use serde_json::json;
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\"");
583 // Ensure deserialization from UI payloads works
584 let parsed: InterpolationType = serde_json::from_str("\"linear\"").unwrap();
585 assert!(matches!(parsed, InterpolationType::Linear));
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();
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);
602 fn test_rotation_waypoint_serialization() {
603 let payload = json!({
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);
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.");
622 let state: FersState = Mutex::new(context);
624 // Test safe access and locking capabilities.
625 let locked_context = state.lock().unwrap();
627 // Executing a read against the state
628 let result = locked_context.get_scenario_as_json();
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());