FERS 0.1.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::{
35 collections::VecDeque,
36 fs,
37 path::PathBuf,
38 sync::{
39 atomic::{AtomicBool, Ordering},
40 mpsc, Mutex,
41 },
42};
43use tauri::{AppHandle, Emitter, Manager, State};
44
45/// Data structure for a single motion waypoint received from the UI.
46///
47/// Coordinates should be in the scenario's define frame (e.g. ENU).
48#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
49pub struct MotionWaypoint {
50 /// Time in seconds.
51 time: f64,
52 /// Easting/X coordinate in meters.
53 x: f64,
54 /// Northing/Y coordinate in meters.
55 y: f64,
56 /// Altitude/Z coordinate in meters (MSL).
57 altitude: f64,
58}
59
60/// Enum for the interpolation type received from the UI.
61#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
62#[serde(rename_all = "lowercase")]
63pub enum InterpolationType {
64 Static,
65 Linear,
66 Cubic,
67}
68
69/// Enum for the rotation angle unit received from the UI.
70#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug, Clone, Copy)]
71#[serde(rename_all = "lowercase")]
72pub enum RotationAngleUnit {
73 Deg,
74 Rad,
75}
76
77/// Data structure for a single interpolated point sent back to the UI.
78///
79/// Represents the physical state of a platform at a specific time step.
80#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
81pub struct InterpolatedMotionPoint {
82 /// X position in meters.
83 x: f64,
84 /// Y position in meters.
85 y: f64,
86 /// Z position in meters.
87 z: f64,
88 /// X velocity in m/s.
89 vx: f64,
90 /// Y velocity in m/s.
91 vy: f64,
92 /// Z velocity in m/s.
93 vz: f64,
94}
95
96/// Data structure for a single rotation waypoint received from the UI.
97#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
98pub struct RotationWaypoint {
99 /// Time in seconds.
100 time: f64,
101 /// Azimuth in compass units (0=North, pi/2 or 90=East).
102 azimuth: f64,
103 /// Elevation in the selected external units (positive up).
104 elevation: f64,
105}
106
107/// Data structure for a single interpolated rotation point sent back to the UI.
108#[derive(serde::Serialize, serde::Deserialize, std::fmt::Debug)]
109pub struct InterpolatedRotationPoint {
110 /// Azimuth in compass units.
111 azimuth: f64,
112 /// Elevation in the selected external units.
113 elevation: f64,
114}
115
116/// Type alias for the managed Tauri state that holds the simulation context.
117///
118/// The `FersContext` is wrapped in a `Mutex` to ensure thread-safe access, as Tauri
119/// may invoke commands from multiple threads concurrently. This alias simplifies
120/// the function signatures of Tauri commands.
121type FersState = Mutex<fers_api::FersContext>;
122
123const DEFAULT_VITA49_TELEMETRY_PACKET_LIMIT: usize = 500;
124const MAX_VITA49_TELEMETRY_PACKET_LIMIT: usize = 1_000_000;
125
126type Vita49TelemetryState = Mutex<Vita49TelemetryBuffer>;
127
128#[derive(Default)]
129struct Vita49TelemetryBuffer {
130 latest_stats: Option<serde_json::Value>,
131 stats_dirty: bool,
132 packets: VecDeque<serde_json::Value>,
133 omitted_packet_trace_events: u64,
134 packet_limit: usize,
135 trace_enabled: bool,
136}
137
138#[derive(serde::Serialize)]
139struct Vita49TelemetryPoll {
140 stats: Option<serde_json::Value>,
141 packets: Vec<serde_json::Value>,
142 omitted_packet_trace_events: u64,
143 has_more: bool,
144}
145
146impl Vita49TelemetryBuffer {
147 fn reset(&mut self, trace_enabled: bool, packet_limit: usize) {
148 self.latest_stats = None;
149 self.stats_dirty = false;
150 self.packets.clear();
151 self.omitted_packet_trace_events = 0;
152 let requested_limit =
153 if packet_limit == 0 { DEFAULT_VITA49_TELEMETRY_PACKET_LIMIT } else { packet_limit };
154 self.packet_limit = requested_limit.clamp(1, MAX_VITA49_TELEMETRY_PACKET_LIMIT);
155 self.trace_enabled = trace_enabled;
156 }
157
158 fn ingest(&mut self, message: fers_api::Vita49TelemetryMessage) {
159 if let Some(stats_json) = message.stats_json {
160 if let Ok(stats) = serde_json::from_str(&stats_json) {
161 self.latest_stats = Some(stats);
162 self.stats_dirty = true;
163 }
164 }
165
166 if !self.trace_enabled {
167 return;
168 }
169
170 if let Some(packet_batch_json) = message.packet_batch_json {
171 let Ok(batch) = serde_json::from_str::<Vec<serde_json::Value>>(&packet_batch_json)
172 else {
173 return;
174 };
175 for packet in batch {
176 if self.packets.len() >= self.packet_limit {
177 self.packets.pop_front();
178 self.omitted_packet_trace_events += 1;
179 }
180 self.packets.push_back(packet);
181 }
182 }
183 }
184
185 fn poll(&mut self, max_packets: usize) -> Vita49TelemetryPoll {
186 let packet_count = max_packets.max(1).min(self.packets.len());
187 let packets = self.packets.drain(..packet_count).collect();
188 let omitted_packet_trace_events = self.omitted_packet_trace_events;
189 self.omitted_packet_trace_events = 0;
190 let stats = if self.stats_dirty {
191 self.stats_dirty = false;
192 self.latest_stats.clone()
193 } else {
194 None
195 };
196 Vita49TelemetryPoll {
197 stats,
198 packets,
199 omitted_packet_trace_events,
200 has_more: !self.packets.is_empty(),
201 }
202 }
203}
204
205#[derive(Default)]
206struct SimulationControlState {
207 cancel_requested: AtomicBool,
208 running: AtomicBool,
209}
210
211impl SimulationControlState {
212 fn try_start(&self) -> Result<(), String> {
213 self.running
214 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
215 .map_err(|_| "A simulation is already running.".to_string())?;
216 self.cancel_requested.store(false, Ordering::SeqCst);
217 Ok(())
218 }
219
220 fn finish(&self) {
221 self.running.store(false, Ordering::SeqCst);
222 }
223}
224
225// --- Tauri Commands ---
226
227/// Sets the output directory for simulation results.
228#[tauri::command]
229fn set_output_directory(dir: String, state: State<'_, FersState>) -> Result<(), String> {
230 state.lock().map_err(|e| e.to_string())?.set_output_directory(&dir)
231}
232
233/// Returns the current FERS logger level.
234#[tauri::command]
235fn get_log_level() -> fers_api::LogLevel {
236 fers_api::get_log_level()
237}
238
239/// Sets the current FERS logger level.
240#[tauri::command]
241fn set_log_level(level: fers_api::LogLevel) -> Result<(), String> {
242 fers_api::set_log_level(level)
243}
244
245/// Loads a FERS scenario from an XML file into the simulation context.
246///
247/// This command replaces any existing in-memory scenario with the one parsed from
248/// the specified file. The file path is provided by the user via the frontend dialog.
249///
250/// # Parameters
251///
252/// * `filepath` - The absolute or relative path to the FERS XML scenario file.
253/// * `state` - Tauri-managed state containing the shared `FersContext`.
254///
255/// # Returns
256///
257/// * `Ok(Vec<String>)` containing any deduplicated non-fatal warnings detected while loading.
258/// * `Err(String)` containing an error message if loading failed (e.g., file not found,
259/// invalid XML, or a Mutex lock error).
260///
261/// # Example (from frontend)
262///
263/// ```typescript
264/// import { invoke } from '@tauri-apps/api/core';
265/// await invoke('load_scenario_from_xml_file', { filepath: '/path/to/scenario.xml' });
266/// ```
267#[tauri::command]
268fn load_scenario_from_xml_file(
269 filepath: String,
270 state: State<'_, FersState>,
271) -> Result<Vec<String>, String> {
272 state.lock().map_err(|e| e.to_string())?.load_scenario_from_xml_file(&filepath)
273}
274
275/// Retrieves the current in-memory scenario as a JSON string.
276///
277/// This command serializes the simulation state into JSON format, allowing the
278/// frontend to display and edit the scenario. The JSON structure mirrors the
279/// internal representation used by `libfers`.
280///
281/// # Parameters
282///
283/// * `state` - Tauri-managed state containing the shared `FersContext`.
284///
285/// # Returns
286///
287/// * `Ok(String)` containing the JSON representation of the scenario.
288/// * `Err(String)` containing an error message if serialization failed or if the
289/// Mutex could not be locked.
290///
291/// # Example (from frontend)
292///
293/// ```typescript
294/// import { invoke } from '@tauri-apps/api/core';
295/// const scenarioJson = await invoke<string>('get_scenario_as_json');
296/// const scenario = JSON.parse(scenarioJson);
297/// ```
298#[tauri::command]
299fn get_scenario_as_json(state: State<'_, FersState>) -> Result<String, String> {
300 state.lock().map_err(|e| e.to_string())?.get_scenario_as_json()
301}
302
303/// Retrieves the current in-memory scenario as a FERS XML string.
304///
305/// This command is typically used when the user wants to export the scenario
306/// (potentially modified in the UI) back to the standard FERS XML format for
307/// sharing or archival.
308///
309/// # Parameters
310///
311/// * `state` - Tauri-managed state containing the shared `FersContext`.
312///
313/// # Returns
314///
315/// * `Ok(String)` containing the XML representation of the scenario.
316/// * `Err(String)` containing an error message if serialization failed or if the
317/// Mutex could not be locked.
318///
319/// # Example (from frontend)
320///
321/// ```typescript
322/// import { invoke } from '@tauri-apps/api/core';
323/// const scenarioXml = await invoke<string>('get_scenario_as_xml');
324/// // Save scenarioXml to a file using Tauri's fs plugin
325/// ```
326#[tauri::command]
327fn get_scenario_as_xml(state: State<'_, FersState>) -> Result<String, String> {
328 state.lock().map_err(|e| e.to_string())?.get_scenario_as_xml()
329}
330
331/// Updates the in-memory scenario from a JSON string provided by the frontend.
332///
333/// This is the primary method for applying changes made in the UI back to the
334/// simulation engine. The JSON is deserialized and used to rebuild the internal
335/// C++ world representation.
336///
337/// # Parameters
338///
339/// * `json` - A JSON string representing the modified scenario structure.
340/// * `state` - Tauri-managed state containing the shared `FersContext`.
341///
342/// # Returns
343///
344/// * `Ok(Vec<String>)` containing any deduplicated non-fatal warnings detected while updating.
345/// * `Err(String)` containing an error message if deserialization failed, the JSON
346/// structure was invalid, or the Mutex could not be locked.
347///
348/// # Example (from frontend)
349///
350/// ```typescript
351/// import { invoke } from '@tauri-apps/api/core';
352/// const updatedScenario = { /* modified scenario object */ };
353/// await invoke('update_scenario_from_json', { json: JSON.stringify(updatedScenario) });
354/// ```
355#[tauri::command]
356fn update_scenario_from_json(
357 json: String,
358 state: State<'_, FersState>,
359) -> Result<Vec<String>, String> {
360 state.lock().map_err(|e| e.to_string())?.update_scenario_from_json(&json)
361}
362
363/// Triggers the simulation based on the current in-memory scenario.
364///
365/// This command immediately returns `Ok(())` and spawns a background thread to
366/// perform the actual computationally intensive simulation. This prevents the UI
367/// from freezing. The result of the simulation (success or failure) is
368/// communicated back to the frontend via Tauri events.
369///
370/// # Parameters
371///
372/// * `app_handle` - The Tauri application handle, used to access managed state
373/// and emit events.
374///
375/// # Events Emitted
376///
377/// * `simulation-output-metadata` - Emitted with metadata JSON before completion.
378/// * `simulation-complete` - Emitted with `()` as payload on successful completion.
379/// * `simulation-error` - Emitted with a `String` error message on failure.
380/// * `simulation-progress` - Emitted periodically with `{ message: String, current: i32, total: i32 }`.
381#[tauri::command]
382fn run_simulation(
383 app_handle: AppHandle,
384 control_state: State<'_, SimulationControlState>,
385) -> Result<(), String> {
386 control_state.try_start()?;
387 // Clone the AppHandle so we can move it into the background thread.
388 let app_handle_clone = app_handle.clone();
389
390 // Spawn a new thread to run the blocking C++ simulation.
391 std::thread::spawn(move || {
392 // Retrieve the managed state within the new thread.
393 let fers_state: State<'_, FersState> = app_handle_clone.state();
394 let control_state: State<'_, SimulationControlState> = app_handle_clone.state();
395 let result = fers_state.lock().map_err(|e| e.to_string()).and_then(|context| {
396 context.run_simulation(&app_handle_clone, &control_state.cancel_requested)
397 });
398
399 // Emit an event to the frontend based on the simulation result.
400 match result {
401 Ok(fers_api::SimulationRunOutcome::Completed(metadata_json)) => {
402 app_handle_clone
403 .emit("simulation-output-metadata", metadata_json)
404 .expect("Failed to emit simulation-output-metadata event");
405 app_handle_clone
406 .emit("simulation-complete", ())
407 .expect("Failed to emit simulation-complete event");
408 }
409 Ok(fers_api::SimulationRunOutcome::Cancelled(metadata_json)) => {
410 app_handle_clone
411 .emit("simulation-output-metadata", metadata_json.clone())
412 .expect("Failed to emit simulation-output-metadata event");
413 app_handle_clone
414 .emit("simulation-cancelled", metadata_json)
415 .expect("Failed to emit simulation-cancelled event");
416 }
417 Err(e) => {
418 app_handle_clone
419 .emit("simulation-error", e)
420 .expect("Failed to emit simulation-error event");
421 }
422 }
423 control_state.finish();
424 });
425
426 // Return immediately, allowing the UI to remain responsive.
427 Ok(())
428}
429
430#[tauri::command]
431fn start_vita49_stream(
432 app_handle: AppHandle,
433 config: fers_api::Vita49StreamConfig,
434 control_state: State<'_, SimulationControlState>,
435) -> Result<(), String> {
436 control_state.try_start()?;
437 let app_handle_clone = app_handle.clone();
438
439 std::thread::spawn(move || {
440 let fers_state: State<'_, FersState> = app_handle_clone.state();
441 let control_state: State<'_, SimulationControlState> = app_handle_clone.state();
442 let telemetry_state: State<'_, Vita49TelemetryState> = app_handle_clone.state();
443 if let Ok(mut telemetry) = telemetry_state.lock() {
444 telemetry.reset(config.trace_enabled, config.packet_trace_ring_size);
445 }
446
447 let (telemetry_sender, telemetry_receiver) =
448 mpsc::channel::<fers_api::Vita49TelemetryMessage>();
449 let telemetry_worker_app_handle = app_handle_clone.clone();
450 let telemetry_worker = std::thread::spawn(move || {
451 let telemetry_state: State<'_, Vita49TelemetryState> =
452 telemetry_worker_app_handle.state();
453 for message in telemetry_receiver {
454 let Ok(mut telemetry) = telemetry_state.lock() else {
455 break;
456 };
457 telemetry.ingest(message);
458 }
459 });
460
461 let result = fers_state.lock().map_err(|e| e.to_string()).and_then(|context| {
462 context.run_vita49_stream(
463 &app_handle_clone,
464 &control_state.cancel_requested,
465 &config,
466 Some(&telemetry_sender),
467 )
468 });
469 drop(telemetry_sender);
470 let _ = telemetry_worker.join();
471
472 match result {
473 Ok(fers_api::SimulationRunOutcome::Completed(metadata_json)) => {
474 app_handle_clone
475 .emit("vita49-output-metadata", metadata_json.clone())
476 .expect("Failed to emit vita49-output-metadata event");
477 app_handle_clone
478 .emit("vita49-stream-complete", metadata_json)
479 .expect("Failed to emit vita49-stream-complete event");
480 }
481 Ok(fers_api::SimulationRunOutcome::Cancelled(metadata_json)) => {
482 app_handle_clone
483 .emit("vita49-output-metadata", metadata_json.clone())
484 .expect("Failed to emit vita49-output-metadata event");
485 app_handle_clone
486 .emit("simulation-cancelled", metadata_json.clone())
487 .expect("Failed to emit simulation-cancelled event");
488 app_handle_clone
489 .emit("vita49-stream-cancelled", metadata_json)
490 .expect("Failed to emit vita49-stream-cancelled event");
491 }
492 Err(e) => {
493 app_handle_clone
494 .emit("vita49-stream-error", e)
495 .expect("Failed to emit vita49-stream-error event");
496 }
497 }
498 control_state.finish();
499 });
500
501 Ok(())
502}
503
504#[tauri::command]
505fn poll_vita49_telemetry(
506 max_packets: usize,
507 telemetry_state: State<'_, Vita49TelemetryState>,
508) -> Result<Vita49TelemetryPoll, String> {
509 Ok(telemetry_state.lock().map_err(|e| e.to_string())?.poll(max_packets))
510}
511
512#[tauri::command]
513fn stop_simulation(control_state: State<'_, SimulationControlState>) -> Result<(), String> {
514 control_state.cancel_requested.store(true, Ordering::SeqCst);
515 Ok(())
516}
517
518fn sanitize_file_stem(name: &str) -> String {
519 let sanitized: String =
520 name.chars().map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }).collect();
521 if sanitized.is_empty() {
522 "simulation".to_string()
523 } else {
524 sanitized
525 }
526}
527
528/// Writes the most recent simulation output metadata JSON next to the generated HDF5 files.
529#[tauri::command]
530fn export_output_metadata_json(state: State<'_, FersState>) -> Result<String, String> {
531 let metadata_json = state.lock().map_err(|e| e.to_string())?.get_last_output_metadata_json()?;
532 let metadata: serde_json::Value = serde_json::from_str(&metadata_json)
533 .map_err(|e| format!("Failed to parse output metadata JSON: {}", e))?;
534
535 let simulation_name =
536 metadata.get("simulation_name").and_then(serde_json::Value::as_str).unwrap_or("simulation");
537 let output_directory = metadata
538 .get("output_directory")
539 .and_then(serde_json::Value::as_str)
540 .filter(|value| !value.is_empty())
541 .unwrap_or(".");
542
543 let mut output_path = PathBuf::from(output_directory);
544 fs::create_dir_all(&output_path)
545 .map_err(|e| format!("Failed to create metadata output directory: {}", e))?;
546 output_path.push(format!("{}_metadata.json", sanitize_file_stem(simulation_name)));
547
548 fs::write(&output_path, metadata_json)
549 .map_err(|e| format!("Failed to write metadata JSON: {}", e))?;
550
551 Ok(output_path.to_string_lossy().to_string())
552}
553
554/// Generates a KML visualization file for the current in-memory scenario.
555///
556/// This command spawns a background thread to handle file I/O and KML generation,
557/// preventing the UI from freezing. The result is communicated via events.
558///
559/// # Parameters
560///
561/// * `output_path` - The absolute file path where the KML file should be saved.
562/// * `app_handle` - The Tauri application handle.
563///
564/// # Events Emitted
565///
566/// * `kml-generation-complete` - Emitted with the output path `String` on success.
567/// * `kml-generation-error` - Emitted with a `String` error message on failure.
568#[tauri::command]
569fn generate_kml(output_path: String, app_handle: AppHandle) -> Result<(), String> {
570 let app_handle_clone = app_handle.clone();
571 std::thread::spawn(move || {
572 let fers_state: State<'_, FersState> = app_handle_clone.state();
573 let result = fers_state
574 .lock()
575 .map_err(|e| e.to_string())
576 .and_then(|context| context.generate_kml(&output_path));
577
578 match result {
579 Ok(_) => {
580 app_handle_clone
581 .emit("kml-generation-complete", &output_path)
582 .expect("Failed to emit kml-generation-complete event");
583 }
584 Err(e) => {
585 app_handle_clone
586 .emit("kml-generation-error", e)
587 .expect("Failed to emit kml-generation-error event");
588 }
589 }
590 });
591 Ok(())
592}
593
594/// A stateless command to calculate an interpolated motion path.
595///
596/// This command delegates to the `libfers` core to calculate a path from a given
597/// set of waypoints, ensuring the UI visualization is identical to the path
598/// the simulation would use.
599///
600/// # Parameters
601/// * `waypoints` - A vector of motion waypoints.
602/// * `interp_type` - The interpolation algorithm to use ('static', 'linear', 'cubic').
603/// * `num_points` - The desired number of points for the final path.
604///
605/// # Returns
606/// * `Ok(Vec<InterpolatedPoint>)` - The calculated path points.
607/// * `Err(String)` - An error message if the path calculation failed.
608#[tauri::command]
609fn get_interpolated_motion_path(
610 waypoints: Vec<MotionWaypoint>,
611 interp_type: InterpolationType,
612 num_points: usize,
613) -> Result<Vec<InterpolatedMotionPoint>, String> {
614 fers_api::get_interpolated_motion_path(waypoints, interp_type, num_points)
615}
616
617/// A stateless command to calculate an interpolated rotation path.
618///
619/// This command delegates to the `libfers` core to calculate a rotation path from a given
620/// set of waypoints. It is used by the UI to preview how the simulation will interpolate
621/// orientation changes over time.
622///
623/// # Parameters
624/// * `waypoints` - A vector of rotation waypoints in the selected external angle unit.
625/// * `interp_type` - The interpolation algorithm to use ('static', 'linear', 'cubic').
626/// * `angle_unit` - The angle unit to use for both input and output.
627/// * `num_points` - The desired number of points for the final path.
628///
629/// # Returns
630/// * `Ok(Vec<InterpolatedRotationPoint>)` - The calculated rotation points.
631/// * `Err(String)` - An error message if the calculation failed.
632#[tauri::command]
633fn get_interpolated_rotation_path(
634 waypoints: Vec<RotationWaypoint>,
635 interp_type: InterpolationType,
636 angle_unit: RotationAngleUnit,
637 num_points: usize,
638) -> Result<Vec<InterpolatedRotationPoint>, String> {
639 fers_api::get_interpolated_rotation_path(waypoints, interp_type, angle_unit, num_points)
640}
641
642/// Retrieves a 2D antenna gain pattern for visualization.
643///
644/// This command samples the antenna model loaded in the current simulation context.
645/// It is stateful because it relies on the antenna assets defined in the loaded scenario.
646///
647/// # Parameters
648/// * `antenna_id` - The unique ID of the antenna asset to sample.
649/// * `az_samples` - Resolution along the azimuth axis (e.g., 360).
650/// * `el_samples` - Resolution along the elevation axis (e.g., 180).
651/// * `frequency` - The frequency in Hz at which to calculate gain (relevant for frequency-dependent antennas).
652/// * `state` - The shared simulation state.
653///
654/// # Returns
655/// * `Ok(AntennaPatternData)` - Struct containing flattened gain array and dimensions.
656/// * `Err(String)` - Error if antenna not found or context locked.
657#[tauri::command]
658fn get_antenna_pattern(
659 antenna_id: String,
660 az_samples: usize,
661 el_samples: usize,
662 frequency: f64,
663 state: State<'_, FersState>,
664) -> Result<fers_api::AntennaPatternData, String> {
665 match state.try_lock() {
666 Ok(context) => context.get_antenna_pattern(&antenna_id, az_samples, el_samples, frequency),
667 Err(_) => Err("Backend is busy with another operation".to_string()),
668 }
669}
670
671/// Calculates visual radio links between platforms at a specific time.
672///
673/// This command performs a lightweight geometric and physics check to determine
674/// which platforms can "see" each other. It distinguishes between monostatic,
675/// bistatic, and interference paths based on signal-to-noise ratios.
676///
677/// # Parameters
678/// * `time` - The simulation time in seconds to evaluate.
679/// * `state` - The shared simulation state containing platforms and physics models.
680///
681/// # Returns
682/// * `Ok(Vec<VisualLink>)` - A list of renderable link segments with metadata (type, quality, label).
683/// * `Err(String)` - Error if context access fails.
684#[tauri::command]
685fn get_preview_links(
686 time: f64,
687 state: State<'_, FersState>,
688) -> Result<Vec<fers_api::VisualLink>, String> {
689 match state.try_lock() {
690 Ok(context) => context.calculate_preview_links(time),
691 Err(_) => Ok(vec![]), // backend is busy (simulation/KML running); return empty and retry next frame
692 }
693}
694
695/// Performs a granular state update on a specific simulation item via JSON.
696#[tauri::command]
697fn update_item_from_json(
698 item_type: String,
699 item_id: String,
700 json: String,
701 state: State<'_, FersState>,
702) -> Result<Vec<String>, String> {
703 let context = state.lock().map_err(|e| e.to_string())?;
704 match item_type.as_str() {
705 "Platform" => context.update_platform_from_json(&item_id, &json),
706 "Transmitter" => context.update_transmitter_from_json(&item_id, &json).map(|()| vec![]),
707 "Receiver" => context.update_receiver_from_json(&item_id, &json).map(|()| vec![]),
708 "Target" => context.update_target_from_json(&item_id, &json).map(|()| vec![]),
709 "Monostatic" => context.update_monostatic_from_json(&json).map(|()| vec![]),
710 "Antenna" => context.update_antenna_from_json(&json).map(|()| vec![]),
711 "Waveform" => context.update_waveform_from_json(&json).map(|()| vec![]),
712 "Timing" => context.update_timing_from_json(&item_id, &json).map(|()| vec![]),
713 "GlobalParameters" => context.update_parameters_from_json(&json),
714 _ => Ok(vec![]),
715 }
716}
717
718/// Initializes and runs the Tauri application.
719///
720/// This function is the main entry point for the desktop application. It performs
721/// the following setup steps:
722///
723/// 1. Creates a new `FersContext` by calling the FFI layer. If this fails, it
724/// indicates a linking or initialization problem with `libfers`.
725/// 2. Registers Tauri plugins for file dialogs, file system access, and shell operations.
726/// 3. Stores the `FersContext` in Tauri's managed state, protected by a `Mutex`.
727/// 4. Registers all Tauri commands so they can be invoked from the frontend.
728/// 5. Launches the Tauri application event loop.
729///
730/// # Panics
731///
732/// This function will panic if:
733/// * The `FersContext` cannot be created (indicating a problem with `libfers`).
734/// * The Tauri application fails to start due to misconfiguration.
735///
736/// # Example
737///
738/// This function is typically called from `main.rs`:
739///
740/// ```ignore
741/// fers_ui_lib::run();
742/// ```
743#[cfg_attr(mobile, tauri::mobile_entry_point)]
744pub fn run() {
745 // Attempt to create the FFI context. This validates that libfers is correctly linked.
746 let context = fers_api::FersContext::new()
747 .expect("Failed to create FERS context. Is libfers linked correctly?");
748
749 tauri::Builder::default()
750 // Register Tauri plugins for UI functionality
751 .plugin(tauri_plugin_dialog::init())
752 .plugin(tauri_plugin_opener::init())
753 .plugin(tauri_plugin_fs::init())
754 .setup(|app| {
755 fers_api::register_log_callback(app.handle().clone());
756 Ok(())
757 })
758 // Store the FersContext as managed state, accessible from all commands
759 .manage(Mutex::new(context))
760 .manage(SimulationControlState::default())
761 .manage(Vita49TelemetryState::default())
762 // Register all Tauri commands that can be invoked from the frontend
763 .invoke_handler(tauri::generate_handler![
764 load_scenario_from_xml_file,
765 get_scenario_as_json,
766 get_scenario_as_xml,
767 update_scenario_from_json,
768 run_simulation,
769 start_vita49_stream,
770 poll_vita49_telemetry,
771 stop_simulation,
772 export_output_metadata_json,
773 generate_kml,
774 get_interpolated_motion_path,
775 get_interpolated_rotation_path,
776 get_antenna_pattern,
777 get_preview_links,
778 update_item_from_json,
779 set_output_directory,
780 get_log_level,
781 set_log_level,
782 ])
783 .run(tauri::generate_context!())
784 .expect("error while running tauri application");
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use serde_json::json;
791
792 #[test]
793 fn test_interpolation_type_serialization() {
794 // Assert serialization outputs match the TypeScript string literals exactly
795 assert_eq!(serde_json::to_string(&InterpolationType::Static).unwrap(), "\"static\"");
796 assert_eq!(serde_json::to_string(&InterpolationType::Linear).unwrap(), "\"linear\"");
797 assert_eq!(serde_json::to_string(&InterpolationType::Cubic).unwrap(), "\"cubic\"");
798
799 // Ensure deserialization from UI payloads works
800 let parsed: InterpolationType = serde_json::from_str("\"linear\"").unwrap();
801 assert!(matches!(parsed, InterpolationType::Linear));
802 }
803
804 #[test]
805 fn test_motion_waypoint_serialization() {
806 let wp = MotionWaypoint { time: 1.0, x: 2.0, y: 3.0, altitude: 4.0 };
807 let serialized = serde_json::to_string(&wp).unwrap();
808
809 // Deserialize to generic JSON value to inspect structure
810 let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
811 assert_eq!(parsed["time"], 1.0);
812 assert_eq!(parsed["x"], 2.0);
813 assert_eq!(parsed["y"], 3.0);
814 assert_eq!(parsed["altitude"], 4.0);
815 }
816
817 #[test]
818 fn test_rotation_waypoint_serialization() {
819 let payload = json!({
820 "time": 5.5,
821 "azimuth": 180.0,
822 "elevation": -15.0
823 });
824
825 let wp: RotationWaypoint = serde_json::from_value(payload).unwrap();
826 assert_eq!(wp.time, 5.5);
827 assert_eq!(wp.azimuth, 180.0);
828 assert_eq!(wp.elevation, -15.0);
829 }
830
831 #[test]
832 fn test_fers_state_wrapper() {
833 // Verify that the C++ library correctly linked and can be protected by a Rust Mutex.
834 // This mimics how Tauri maintains the global state internally.
835 let context = fers_api::FersContext::new()
836 .expect("Failed to create FERS context. Check build.rs linking.");
837
838 let state: FersState = Mutex::new(context);
839
840 // Test safe access and locking capabilities.
841 let locked_context = state.lock().unwrap();
842
843 // Executing a read against the state
844 let result = locked_context.get_scenario_as_json();
845
846 // Since it's a completely uninitialized new context with no XML/JSON loaded yet,
847 // it correctly delegates to the backend and evaluates safely without segfaults.
848 assert!(result.is_ok() || result.is_err());
849 }
850
851 #[test]
852 fn test_simulation_control_cancel_flag_does_not_require_context_lock() {
853 let control = SimulationControlState::default();
854 control.try_start().unwrap();
855
856 assert!(!control.cancel_requested.load(Ordering::SeqCst));
857 control.cancel_requested.store(true, Ordering::SeqCst);
858 assert!(control.cancel_requested.load(Ordering::SeqCst));
859
860 control.finish();
861 assert!(!control.running.load(Ordering::SeqCst));
862 }
863}