FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
fers_api.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//! # Safe Rust FFI Wrapper for `libfers`
5//!
6//! This module provides a safe, idiomatic Rust interface to the `libfers` C++
7//! simulation library. It bridges the gap between the raw C-style FFI (generated
8//! by `bindgen`) and the higher-level Rust code used in the Tauri application.
9//!
10//! ## Safety Guarantees
11//!
12//! The module ensures the following safety properties:
13//!
14//! 1. **Automatic Resource Management**: All C-allocated resources (contexts, strings)
15//! are wrapped in RAII types that guarantee cleanup via `Drop`.
16//! 2. **Memory Safety**: Raw pointers from C are only dereferenced within `unsafe`
17//! blocks with documented safety invariants.
18//! 3. **String Conversion**: All C strings are converted to Rust `String`s with
19//! proper UTF-8 validation and null-termination handling.
20//! 4. **Thread Safety**: The `FersContext` is marked as `Send + Sync` because it
21//! will be protected by a `Mutex` in the application layer.
22//!
23//! ## Error Handling
24//!
25//! All fallible operations return `Result<T, String>`, where the error string
26//! contains a human-readable message retrieved from the C library's thread-local
27//! error storage.
28
29use std::ffi::{c_void, CStr, CString};
30use std::os::raw::c_char;
31use std::sync::{
32 atomic::{AtomicBool, AtomicU64, Ordering},
33 mpsc::Sender,
34};
35use tauri::{AppHandle, Emitter};
36
37/// Raw FFI bindings generated by `bindgen` from `libfers/api.h`.
38///
39/// This inner module is kept private to prevent direct access to unsafe FFI
40/// functions. It contains the raw C function declarations and opaque struct types
41/// that mirror the C-API header.
42///
43/// # Safety
44///
45/// All items in this module are `unsafe` to use directly. They require:
46/// * Valid, non-null pointers for all context handles.
47/// * Proper null-termination for all C strings.
48/// * Manual memory management (allocation/deallocation).
49///
50/// The parent module (`fers_api`) provides safe wrappers that enforce these invariants.
51mod ffi {
52 #![allow(non_upper_case_globals)]
53 #![allow(non_camel_case_types)]
54 #![allow(non_snake_case)]
55 #![allow(dead_code)]
56 include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
57}
58
59static LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1);
60const FERS_RUN_CANCELLED: i32 = 2;
61const VITA49_DRAIN_MESSAGE: &str = "Waiting for VITA output stream drain...";
62
63#[derive(serde::Deserialize, Clone, Debug)]
64pub struct Vita49StreamConfig {
65 pub host: String,
66 pub port: u16,
67 pub fullscale: f64,
68 pub epoch_unix_nanoseconds: Option<String>,
69 pub max_udp_payload: u16,
70 pub queue_depth: u32,
71 pub trace_enabled: bool,
72 pub packet_trace_ring_size: usize,
73}
74
75pub enum SimulationRunOutcome {
76 Completed(String),
77 Cancelled(String),
78}
79
80#[derive(Clone, Debug)]
81pub struct Vita49TelemetryMessage {
82 pub stats_json: Option<String>,
83 pub packet_batch_json: Option<String>,
84}
85
86pub type Vita49TelemetrySender = Sender<Vita49TelemetryMessage>;
87
88/// A smart pointer wrapper for C-allocated strings returned from `libfers`.
89///
90/// This type ensures that the memory allocated by the C library (via `strdup`)
91/// is properly freed when the wrapper goes out of scope, preventing memory leaks.
92///
93/// # Memory Management
94///
95/// The wrapped pointer must have been allocated by a `libfers` API function that
96/// transfers ownership to the caller (e.g., `fers_get_scenario_as_json`). The
97/// `Drop` implementation calls `fers_free_string` to release the memory back to
98/// the C allocator.
99///
100/// # Null Handling
101///
102/// If the pointer is null, the wrapper treats it as an empty string. This simplifies
103/// error handling in cases where null indicates "no data" rather than an error.
104struct FersOwnedString(*mut c_char);
105
106impl Drop for FersOwnedString {
107 /// Frees the underlying C string by calling `fers_free_string`.
108 ///
109 /// This is automatically invoked when the wrapper goes out of scope,
110 /// ensuring that no manual cleanup is required by the caller.
111 ///
112 /// # Safety
113 ///
114 /// The pointer must have been allocated by `libfers` (typically via `strdup`)
115 /// and must not have been freed already. The `Drop` trait ensures this is
116 /// called exactly once per instance.
117 fn drop(&mut self) {
118 if !self.0.is_null() {
119 // SAFETY: The pointer was allocated by `libfers` and is valid until we call `fers_free_string`.
120 // We are the sole owner of this pointer.
121 unsafe { ffi::fers_free_string(self.0) };
122 }
123 }
124}
125
126impl FersOwnedString {
127 /// Converts the owned C string to a Rust `String`, consuming the wrapper.
128 ///
129 /// This method performs UTF-8 validation and copies the string data into a
130 /// Rust-managed `String`. The C-allocated memory is freed after the conversion.
131 ///
132 /// # Returns
133 ///
134 /// * `Ok(String)` - The converted string if valid UTF-8.
135 /// * `Err(std::str::Utf8Error)` - If the C string contains invalid UTF-8 bytes.
136 ///
137 /// # Example
138 ///
139 /// ```ignore
140 /// let owned = FersOwnedString(some_c_string_ptr);
141 /// match owned.into_string() {
142 /// Ok(s) => println!("Got string: {}", s),
143 /// Err(e) => eprintln!("Invalid UTF-8: {}", e),
144 /// }
145 /// ```
146 fn into_string(self) -> Result<String, std::str::Utf8Error> {
147 if self.0.is_null() {
148 return Ok(String::new());
149 }
150 // SAFETY: `self.0` is a valid, null-terminated C string from `libfers`.
151 // The `CStr::from_ptr` is safe as long as the pointer is valid.
152 let c_str = unsafe { CStr::from_ptr(self.0) };
153 c_str.to_str().map(|s| s.to_string())
154 }
155}
156
157/// A safe, RAII-style wrapper for the `fers_context_t*` C handle.
158///
159/// This struct encapsulates the lifetime and ownership of a simulation context
160/// created by the `libfers` C++ library. It ensures that:
161/// * The context is created via `fers_context_create` on initialization.
162/// * The context is destroyed via `fers_context_destroy` when dropped.
163/// * The context is never null after successful creation.
164///
165/// # Thread Safety
166///
167/// This type implements `Send` and `Sync` because the underlying C++ context will
168/// be protected by a `Mutex` in the Tauri application. The C++ `FersContext` class
169/// is not thread-safe, but by serializing all access through Rust's `Mutex`, we
170/// ensure that only one thread can call methods on the context at a time.
171///
172/// # Example
173///
174/// ```ignore
175/// use fers_api::FersContext;
176///
177/// let context = FersContext::new().expect("Failed to create context");
178/// context.load_scenario_from_xml_file("scenario.xml")?;
179/// let json = context.get_scenario_as_json()?;
180/// // Context is automatically destroyed when it goes out of scope
181/// ```
182pub struct FersContext {
183 /// The raw pointer to the C++ context object.
184 ///
185 /// This must be a raw pointer because `fers_context_t` is an opaque struct.
186 /// The `Send` and `Sync` traits are manually implemented because we'll wrap this
187 /// context in a Mutex, ensuring that access to the non-thread-safe C++ object
188 /// is properly synchronized.
189 ptr: *mut ffi::fers_context_t,
190}
191
192// SAFETY: The FersContext will be protected by a Mutex. All C-API calls on a single
193// context are not guaranteed to be thread-safe by themselves, but by enforcing
194// serialized access through a Mutex, we make its usage safe across threads.
195unsafe impl Send for FersContext {}
196unsafe impl Sync for FersContext {}
197
198impl Drop for FersContext {
199 /// Destroys the underlying C++ context and frees all associated resources.
200 ///
201 /// This method is automatically called when the `FersContext` goes out of scope.
202 /// It delegates to `fers_context_destroy`, which is responsible for cleaning up
203 /// the C++ `FersContext` object and its owned `World`.
204 ///
205 /// # Safety
206 ///
207 /// The pointer must be valid and non-null. The `Drop` trait guarantees this is
208 /// called exactly once, preventing double-free errors.
209 fn drop(&mut self) {
210 if !self.ptr.is_null() {
211 // SAFETY: `self.ptr` is a valid handle created by `fers_context_create`.
212 // The `Drop` trait ensures this is called exactly once.
213 unsafe { ffi::fers_context_destroy(self.ptr) };
214 }
215 }
216}
217
218/// Retrieves and formats the last error message from the `libfers` C-API.
219///
220/// This helper function is called whenever a C-API function returns an error code.
221/// It queries the thread-local error storage via `fers_get_last_error_message`,
222/// converts the C string to a Rust `String`, and ensures the memory is freed.
223///
224/// # Returns
225///
226/// A human-readable error message. If no error message is available (null pointer),
227/// a default message is returned. If the error message contains invalid UTF-8, an
228/// error describing the UTF-8 issue is returned instead.
229///
230/// # Thread Safety
231///
232/// This function is thread-safe because the error storage in `libfers` is thread-local.
233/// Each thread has its own error message buffer.
234fn get_last_error() -> String {
235 // SAFETY: `fers_get_last_error_message` is a thread-safe FFI function.
236 let error_ptr = unsafe { ffi::fers_get_last_error_message() };
237 if error_ptr.is_null() {
238 "An unknown FFI error occurred.".to_string()
239 } else {
240 // The FersOwnedString wrapper ensures the memory is freed.
241 FersOwnedString(error_ptr)
242 .into_string()
243 .unwrap_or_else(|e| format!("FFI error message contained invalid UTF-8: {}", e))
244 }
245}
246
247fn take_last_warnings() -> Result<Vec<String>, String> {
248 let warnings_ptr = unsafe { ffi::fers_get_last_warning_messages_json() };
249 if warnings_ptr.is_null() {
250 return Ok(vec![]);
251 }
252
253 let warnings_json = FersOwnedString(warnings_ptr).into_string().map_err(|e| e.to_string())?;
254 serde_json::from_str(&warnings_json)
255 .map_err(|e| format!("Failed to decode warning payload from libfers: {}", e))
256}
257
258/// Data structure for simulation progress events emitted to the frontend.
259#[derive(serde::Serialize, Clone)]
260struct ProgressPayload {
261 message: String,
262 current: i32,
263 total: i32,
264}
265
266/// Data structure for raw FERS log lines emitted to the frontend.
267#[derive(serde::Serialize, Clone)]
268struct LogPayload {
269 sequence: u64,
270 level: &'static str,
271 line: String,
272}
273
274#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
275#[serde(rename_all = "UPPERCASE")]
276pub enum LogLevel {
277 Trace,
278 Debug,
279 Info,
280 Warning,
281 Error,
282 Fatal,
283 Off,
284}
285
286impl LogLevel {
287 fn to_ffi(self) -> ffi::fers_log_level_t {
288 match self {
289 Self::Trace => 0,
290 Self::Debug => 1,
291 Self::Info => 2,
292 Self::Warning => 3,
293 Self::Error => 4,
294 Self::Fatal => 5,
295 Self::Off => 6,
296 }
297 }
298
299 fn from_ffi(level: ffi::fers_log_level_t) -> Self {
300 match level {
301 0 => Self::Trace,
302 1 => Self::Debug,
303 2 => Self::Info,
304 3 => Self::Warning,
305 4 => Self::Error,
306 5 => Self::Fatal,
307 6 => Self::Off,
308 _ => Self::Info,
309 }
310 }
311}
312
313fn log_level_label(level: ffi::fers_log_level_t) -> &'static str {
314 match level {
315 0 => "TRACE",
316 1 => "DEBUG",
317 2 => "INFO",
318 3 => "WARNING",
319 4 => "ERROR",
320 5 => "FATAL",
321 6 => "OFF",
322 _ => "UNKNOWN",
323 }
324}
325
326pub fn get_log_level() -> LogLevel {
327 let level = unsafe { ffi::fers_get_log_level() };
328 LogLevel::from_ffi(level)
329}
330
331pub fn set_log_level(level: LogLevel) -> Result<(), String> {
332 let result = unsafe { ffi::fers_configure_logging(level.to_ffi(), std::ptr::null()) };
333 if result == 0 {
334 Ok(())
335 } else {
336 Err(get_last_error())
337 }
338}
339
340/// The C-style callback function registered with the libfers logger.
341extern "C" fn fers_log_callback(
342 level: ffi::fers_log_level_t,
343 line: *const c_char,
344 user_data: *mut c_void,
345) {
346 if user_data.is_null() || line.is_null() {
347 return;
348 }
349
350 // SAFETY: `user_data` is a leaked AppHandle pointer for the lifetime of the Tauri app.
351 let app_handle = unsafe { &*(user_data as *const AppHandle) };
352
353 // SAFETY: `line` is guaranteed by the C-API callback contract to be valid for this call.
354 let line_str = unsafe { CStr::from_ptr(line) }.to_string_lossy().into_owned();
355
356 let payload = LogPayload {
357 sequence: LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed),
358 level: log_level_label(level),
359 line: line_str,
360 };
361
362 let _ = app_handle.emit("fers-log", payload);
363}
364
365pub fn register_log_callback(app_handle: AppHandle) {
366 let app_handle_ptr = Box::into_raw(Box::new(app_handle)) as *mut c_void;
367
368 // SAFETY: The callback and leaked AppHandle pointer remain valid for the Tauri app lifetime.
369 unsafe {
370 ffi::fers_set_log_callback(Some(fers_log_callback), app_handle_ptr);
371 }
372}
373
374/// The C-style callback function passed to `fers_run_simulation`.
375///
376/// This function is invoked by the C++ core to report progress. It reconstructs the
377/// `AppHandle` from the `user_data` pointer and emits a Tauri event to the frontend.
378///
379/// # Safety
380///
381/// This function is marked `unsafe` because it dereferences raw pointers (`message`, `user_data`).
382/// The caller (the C++ library) must guarantee that `message` is a valid, null-terminated
383/// UTF-8 string and that `user_data` is a valid pointer to an `AppHandle`. The pointer
384/// is only valid for the duration of the `fers_run_simulation` call.
385#[allow(clippy::similar_names)]
386extern "C" fn simulation_progress_callback(
387 message: *const c_char,
388 current: i32,
389 total: i32,
390 user_data: *mut c_void,
391) {
392 if user_data.is_null() {
393 return;
394 }
395 // SAFETY: This is safe because we know `user_data` is a pointer to the AppHandle,
396 // which is guaranteed to be valid for the lifetime of the simulation call.
397 let app_handle = unsafe { &*(user_data as *const AppHandle) };
398
399 // SAFETY: `message` is guaranteed by the C-API to be a valid, null-terminated string.
400 let message_str = unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned();
401
402 let payload = ProgressPayload { message: message_str, current, total };
403
404 // Emit the event to the frontend. If this fails, there's little we can do
405 // from the callback, so we just let it panic in debug builds.
406 app_handle
407 .emit("simulation-progress", payload)
408 .expect("Failed to emit simulation-progress event");
409}
410
411extern "C" fn vita49_lifecycle_callback(
412 message: *const c_char,
413 _current: i32,
414 _total: i32,
415 user_data: *mut c_void,
416) {
417 if user_data.is_null() || message.is_null() {
418 return;
419 }
420
421 let app_handle = unsafe { &*(user_data as *const AppHandle) };
422 let message_str = unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned();
423
424 if message_str == VITA49_DRAIN_MESSAGE {
425 app_handle
426 .emit("vita49-stream-draining", message_str)
427 .expect("Failed to emit vita49-stream-draining event");
428 }
429}
430
431extern "C" fn simulation_cancel_callback(user_data: *mut c_void) -> i32 {
432 if user_data.is_null() {
433 return 0;
434 }
435
436 let cancel_flag = unsafe { &*(user_data as *const AtomicBool) };
437 i32::from(cancel_flag.load(Ordering::Relaxed))
438}
439
440extern "C" fn vita49_telemetry_callback(
441 stats_json: *const c_char,
442 packet_batch_json: *const c_char,
443 user_data: *mut c_void,
444) {
445 if user_data.is_null() {
446 return;
447 }
448
449 let sender = unsafe { &*(user_data as *const Vita49TelemetrySender) };
450
451 let stats_json = if stats_json.is_null() {
452 None
453 } else {
454 Some(unsafe { CStr::from_ptr(stats_json) }.to_string_lossy().into_owned())
455 };
456
457 let packet_batch_json = if packet_batch_json.is_null() {
458 None
459 } else {
460 Some(unsafe { CStr::from_ptr(packet_batch_json) }.to_string_lossy().into_owned())
461 };
462
463 let _ = sender.send(Vita49TelemetryMessage { stats_json, packet_batch_json });
464}
465
466/// A safe RAII wrapper for the antenna pattern data returned by the C-API.
467struct FersAntennaPatternData(*mut ffi::fers_antenna_pattern_data_t);
468impl Drop for FersAntennaPatternData {
469 fn drop(&mut self) {
470 if !self.0.is_null() {
471 // SAFETY: The pointer is valid and owned by this struct.
472 unsafe { ffi::fers_free_antenna_pattern_data(self.0) };
473 }
474 }
475}
476
477/// Data structure for antenna pattern data sent to the frontend.
478///
479/// This struct flattens the 2D gain data into a 1D vector for easy serialization.
480#[derive(serde::Serialize, std::fmt::Debug)]
481pub struct AntennaPatternData {
482 /// Flattened array of linear gain values (normalized 0.0 to 1.0).
483 /// Ordered row-major: Elevation rows, then Azimuth columns.
484 gains: Vec<f64>,
485 /// Number of samples along the azimuth axis (360 degrees).
486 az_count: usize,
487 /// Number of samples along the elevation axis (180 degrees).
488 el_count: usize,
489 /// The peak linear gain found in the pattern, used for normalization.
490 max_gain: f64,
491}
492
493// Helper wrapper for the visual link list
494struct FersVisualLinkList(*mut ffi::fers_visual_link_list_t);
495
496impl Drop for FersVisualLinkList {
497 fn drop(&mut self) {
498 if !self.0.is_null() {
499 unsafe { ffi::fers_free_preview_links(self.0) };
500 }
501 }
502}
503
504/// Represents a visual link segment for 3D rendering.
505///
506/// This struct maps C-style enums to integers for consumption by the TypeScript frontend.
507#[derive(serde::Serialize, std::fmt::Debug)]
508pub struct VisualLink {
509 /// The type of radio link.
510 /// * `0`: Monostatic (Tx -> Tgt -> Rx, where Tx==Rx)
511 /// * `1`: Bistatic Illuminator (Tx -> Tgt)
512 /// * `2`: Bistatic Scattered (Tgt -> Rx)
513 /// * `3`: Direct Interference (Tx -> Rx)
514 pub link_type: u8,
515 /// The radiometric quality of the link.
516 /// * `0`: Strong (SNR > 0 dB)
517 /// * `1`: Weak (SNR < 0 dB, visible but sub-noise)
518 pub quality: u8,
519 /// A pre-formatted label string (e.g., "-95 dBm").
520 pub label: String,
521 /// The ID of the start component for this segment.
522 pub source_id: String,
523 /// The ID of the end component for this segment.
524 pub dest_id: String,
525 /// The ID of the original transmitter (useful for scattered paths).
526 pub origin_id: String,
527 /// RCS in m^2 for this path. Negative if not applicable (non-monostatic links).
528 pub rcs: f64,
529 /// Received power in dBm with actual RCS applied. -999 if not applicable.
530 pub actual_power_dbm: f64,
531 /// Numeric value represented by `label`, in the label's unit.
532 pub display_value: f64,
533}
534
535impl FersContext {
536 /// Creates a new `FersContext` by calling the C-API constructor.
537 ///
538 /// This function allocates a new C++ `FersContext` object on the heap via
539 /// `fers_context_create`. The returned context is initially empty and must be
540 /// populated by loading a scenario (via `load_scenario_from_xml_file` or
541 /// `update_scenario_from_json`).
542 ///
543 /// # Returns
544 ///
545 /// * `Some(FersContext)` - If the context was successfully created.
546 /// * `None` - If allocation failed (e.g., out of memory) or if the C++ constructor
547 /// threw an exception.
548 ///
549 /// # Example
550 ///
551 /// ```ignore
552 /// let context = FersContext::new().expect("Failed to create FERS context");
553 /// ```
554 pub fn new() -> Option<Self> {
555 // SAFETY: `fers_context_create` is a simple constructor function with no preconditions.
556 let ptr = unsafe { ffi::fers_context_create() };
557 if ptr.is_null() {
558 None
559 } else {
560 Some(Self { ptr })
561 }
562 }
563
564 /// Sets the output directory for simulation results.
565 ///
566 /// # Parameters
567 ///
568 /// * `dir` - A UTF-8 string containing the path to the output directory.
569 ///
570 /// # Returns
571 ///
572 /// * `Ok(())` - If the directory was successfully set.
573 /// * `Err(String)` - If an error occurred.
574 pub fn set_output_directory(&self, dir: &str) -> Result<(), String> {
575 let c_dir = CString::new(dir).map_err(|e| e.to_string())?;
576 // SAFETY: We pass a valid context pointer and a null-terminated C string.
577 let result = unsafe { ffi::fers_set_output_directory(self.ptr, c_dir.as_ptr()) };
578 if result == 0 {
579 Ok(())
580 } else {
581 Err(get_last_error())
582 }
583 }
584
585 pub fn use_hdf5_output(&self) -> Result<(), String> {
586 let result = unsafe { ffi::fers_use_hdf5_output(self.ptr) };
587 if result == 0 {
588 Ok(())
589 } else {
590 Err(get_last_error())
591 }
592 }
593
594 pub fn enable_vita49_udp_output(&self, host: &str, port: u16) -> Result<(), String> {
595 let c_host = CString::new(host).map_err(|e| e.to_string())?;
596 let result = unsafe { ffi::fers_enable_vita49_udp_output(self.ptr, c_host.as_ptr(), port) };
597 if result == 0 {
598 Ok(())
599 } else {
600 Err(get_last_error())
601 }
602 }
603
604 pub fn set_vita49_fullscale(&self, fullscale: f64) -> Result<(), String> {
605 let result = unsafe { ffi::fers_set_vita49_fullscale(self.ptr, fullscale) };
606 if result == 0 {
607 Ok(())
608 } else {
609 Err(get_last_error())
610 }
611 }
612
613 pub fn set_vita49_epoch_unix_nanoseconds(&self, epoch: u64) -> Result<(), String> {
614 let result = unsafe { ffi::fers_set_vita49_epoch_unix_nanoseconds(self.ptr, epoch) };
615 if result == 0 {
616 Ok(())
617 } else {
618 Err(get_last_error())
619 }
620 }
621
622 pub fn set_vita49_max_udp_payload(&self, max_udp_payload: u16) -> Result<(), String> {
623 let result = unsafe { ffi::fers_set_vita49_max_udp_payload(self.ptr, max_udp_payload) };
624 if result == 0 {
625 Ok(())
626 } else {
627 Err(get_last_error())
628 }
629 }
630
631 pub fn set_vita49_queue_depth(&self, queue_depth: u32) -> Result<(), String> {
632 let result = unsafe { ffi::fers_set_vita49_queue_depth(self.ptr, queue_depth) };
633 if result == 0 {
634 Ok(())
635 } else {
636 Err(get_last_error())
637 }
638 }
639
640 pub fn set_vita49_packet_trace_enabled(&self, enabled: bool) -> Result<(), String> {
641 let result = unsafe {
642 ffi::fers_set_vita49_packet_trace_enabled(self.ptr, if enabled { 1 } else { 0 })
643 };
644 if result == 0 {
645 Ok(())
646 } else {
647 Err(get_last_error())
648 }
649 }
650
651 pub fn configure_vita49_stream(&self, config: &Vita49StreamConfig) -> Result<(), String> {
652 self.enable_vita49_udp_output(&config.host, config.port)?;
653 self.set_vita49_fullscale(config.fullscale)?;
654 if let Some(epoch) = &config.epoch_unix_nanoseconds {
655 let parsed_epoch =
656 epoch.parse::<u64>().map_err(|e| format!("Invalid VITA49 epoch: {}", e))?;
657 self.set_vita49_epoch_unix_nanoseconds(parsed_epoch)?;
658 }
659 self.set_vita49_max_udp_payload(config.max_udp_payload)?;
660 self.set_vita49_queue_depth(config.queue_depth)?;
661 self.set_vita49_packet_trace_enabled(config.trace_enabled)?;
662 Ok(())
663 }
664
665 /// Loads a FERS scenario from an XML file into the context.
666 ///
667 /// This method replaces any existing scenario in the context with the one parsed
668 /// from the specified file. The XML is validated against the FERS schema if
669 /// validation is enabled in the C++ library.
670 ///
671 /// # Parameters
672 ///
673 /// * `filepath` - A UTF-8 string containing the absolute or relative path to the
674 /// FERS XML scenario file.
675 ///
676 /// # Returns
677 ///
678 /// * `Ok(())` - If the scenario was successfully loaded and parsed.
679 /// * `Err(String)` - If the file could not be read, the XML was invalid, or a
680 /// C++ exception was thrown. The error string contains details.
681 ///
682 /// # Example
683 ///
684 /// ```ignore
685 /// context.load_scenario_from_xml_file("/path/to/scenario.xml")?;
686 /// ```
687 pub fn load_scenario_from_xml_file(&self, filepath: &str) -> Result<Vec<String>, String> {
688 let c_filepath = CString::new(filepath).map_err(|e| e.to_string())?;
689 // SAFETY: We pass a valid context pointer and a null-terminated C string.
690 // The function returns 0 on success.
691 let result =
692 unsafe { ffi::fers_load_scenario_from_xml_file(self.ptr, c_filepath.as_ptr(), 1) };
693 if result == 0 {
694 take_last_warnings()
695 } else {
696 Err(get_last_error())
697 }
698 }
699
700 /// Retrieves the current in-memory scenario as a JSON string.
701 ///
702 /// This method serializes the C++ `World` object into JSON format, which mirrors
703 /// the structure used by the frontend. It is typically used to populate the UI
704 /// after loading a scenario from XML.
705 ///
706 /// # Returns
707 ///
708 /// * `Ok(String)` - The JSON representation of the scenario.
709 /// * `Err(String)` - If serialization failed or the JSON contains invalid UTF-8.
710 ///
711 /// # Memory Management
712 ///
713 /// The returned string is a Rust-owned `String`. The underlying C-allocated memory
714 /// is automatically freed by the `FersOwnedString` wrapper.
715 ///
716 /// # Example
717 ///
718 /// ```ignore
719 /// let json = context.get_scenario_as_json()?;
720 /// let scenario: serde_json::Value = serde_json::from_str(&json)?;
721 /// ```
722 pub fn get_scenario_as_json(&self) -> Result<String, String> {
723 // SAFETY: We pass a valid context pointer. The function returns a C string
724 // that we must free.
725 let json_ptr = unsafe { ffi::fers_get_scenario_as_json(self.ptr) };
726 if json_ptr.is_null() {
727 return Err(get_last_error());
728 }
729 // FersOwnedString takes ownership and will free the memory on drop.
730 FersOwnedString(json_ptr).into_string().map_err(|e| e.to_string())
731 }
732
733 /// Retrieves the current in-memory scenario as a FERS XML string.
734 ///
735 /// This method serializes the C++ `World` object into the standard FERS XML format.
736 /// It is typically used when the user wants to export the scenario (potentially
737 /// modified in the UI) back to a file.
738 ///
739 /// # Returns
740 ///
741 /// * `Ok(String)` - The XML representation of the scenario.
742 /// * `Err(String)` - If serialization failed or the XML contains invalid UTF-8.
743 ///
744 /// # Memory Management
745 ///
746 /// The returned string is a Rust-owned `String`. The underlying C-allocated memory
747 /// is automatically freed by the `FersOwnedString` wrapper.
748 ///
749 /// # Example
750 ///
751 /// ```ignore
752 /// let xml = context.get_scenario_as_xml()?;
753 /// std::fs::write("exported_scenario.xml", xml)?;
754 /// ```
755 pub fn get_scenario_as_xml(&self) -> Result<String, String> {
756 // SAFETY: We pass a valid context pointer. The function returns a C string
757 // that we must free.
758 let xml_ptr = unsafe { ffi::fers_get_scenario_as_xml(self.ptr) };
759 if xml_ptr.is_null() {
760 return Err(get_last_error());
761 }
762 // FersOwnedString takes ownership and will free the memory on drop.
763 FersOwnedString(xml_ptr).into_string().map_err(|e| e.to_string())
764 }
765
766 /// Updates the in-memory scenario from a JSON string.
767 ///
768 /// This method is the primary way for the UI to push modified scenario data back
769 /// to the C++ simulation engine. It deserializes the JSON and rebuilds the internal
770 /// `World` object, replacing any existing scenario.
771 ///
772 /// # Parameters
773 ///
774 /// * `json` - A UTF-8 JSON string representing the scenario. The structure must
775 /// match the schema expected by `libfers` (the same format returned by
776 /// `get_scenario_as_json`).
777 ///
778 /// # Returns
779 ///
780 /// * `Ok(())` - If the scenario was successfully deserialized and loaded.
781 /// * `Err(String)` - If the JSON was malformed, contained invalid data, or a C++
782 /// exception was thrown. The error string contains details.
783 ///
784 /// # Example
785 ///
786 /// ```ignore
787 /// let modified_json = /* JSON from UI */;
788 /// context.update_scenario_from_json(&modified_json)?;
789 /// ```
790 pub fn update_scenario_from_json(&self, json: &str) -> Result<Vec<String>, String> {
791 let c_json = CString::new(json).map_err(|e| e.to_string())?;
792 // SAFETY: We pass a valid context pointer and a null-terminated C string.
793 // The function returns 0 on success.
794 let result = unsafe { ffi::fers_update_scenario_from_json(self.ptr, c_json.as_ptr()) };
795 if result == 0 {
796 take_last_warnings()
797 } else {
798 Err(get_last_error())
799 }
800 }
801
802 /// Updates a single platform's paths and name from JSON.
803 pub fn update_platform_from_json(
804 &self,
805 id_str: &str,
806 json: &str,
807 ) -> Result<Vec<String>, String> {
808 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
809 let c_json = CString::new(json).map_err(|e| e.to_string())?;
810 let result = unsafe { ffi::fers_update_platform_from_json(self.ptr, id, c_json.as_ptr()) };
811 if result == 0 {
812 take_last_warnings()
813 } else {
814 Err(get_last_error())
815 }
816 }
817
818 /// Updates the global simulation parameters from JSON.
819 pub fn update_parameters_from_json(&self, json: &str) -> Result<Vec<String>, String> {
820 let c_json = CString::new(json).map_err(|e| e.to_string())?;
821 let result = unsafe { ffi::fers_update_parameters_from_json(self.ptr, c_json.as_ptr()) };
822 if result == 0 {
823 take_last_warnings()
824 } else {
825 Err(get_last_error())
826 }
827 }
828
829 /// Updates a single antenna from JSON.
830 pub fn update_antenna_from_json(&self, json: &str) -> Result<(), String> {
831 let c_json = CString::new(json).map_err(|e| e.to_string())?;
832 let result = unsafe { ffi::fers_update_antenna_from_json(self.ptr, c_json.as_ptr()) };
833 if result == 0 {
834 Ok(())
835 } else {
836 Err(get_last_error())
837 }
838 }
839
840 /// Updates a single waveform from JSON.
841 pub fn update_waveform_from_json(&self, json: &str) -> Result<(), String> {
842 let c_json = CString::new(json).map_err(|e| e.to_string())?;
843 let result = unsafe { ffi::fers_update_waveform_from_json(self.ptr, c_json.as_ptr()) };
844 if result == 0 {
845 Ok(())
846 } else {
847 Err(get_last_error())
848 }
849 }
850
851 /// Updates a single transmitter from JSON.
852 pub fn update_transmitter_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
853 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
854 let c_json = CString::new(json).map_err(|e| e.to_string())?;
855 let result =
856 unsafe { ffi::fers_update_transmitter_from_json(self.ptr, id, c_json.as_ptr()) };
857 if result == 0 {
858 Ok(())
859 } else {
860 Err(get_last_error())
861 }
862 }
863
864 /// Updates a single receiver from JSON.
865 pub fn update_receiver_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
866 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
867 let c_json = CString::new(json).map_err(|e| e.to_string())?;
868 let result = unsafe { ffi::fers_update_receiver_from_json(self.ptr, id, c_json.as_ptr()) };
869 if result == 0 {
870 Ok(())
871 } else {
872 Err(get_last_error())
873 }
874 }
875
876 /// Updates a single target from JSON.
877 pub fn update_target_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
878 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
879 let c_json = CString::new(json).map_err(|e| e.to_string())?;
880 let result = unsafe { ffi::fers_update_target_from_json(self.ptr, id, c_json.as_ptr()) };
881 if result == 0 {
882 Ok(())
883 } else {
884 Err(get_last_error())
885 }
886 }
887
888 /// Updates a monostatic radar from JSON.
889 pub fn update_monostatic_from_json(&self, json: &str) -> Result<(), String> {
890 let c_json = CString::new(json).map_err(|e| e.to_string())?;
891 let result = unsafe { ffi::fers_update_monostatic_from_json(self.ptr, c_json.as_ptr()) };
892 if result == 0 {
893 Ok(())
894 } else {
895 Err(get_last_error())
896 }
897 }
898
899 /// Updates a single timing source from JSON.
900 pub fn update_timing_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
901 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
902 let c_json = CString::new(json).map_err(|e| e.to_string())?;
903 let result = unsafe { ffi::fers_update_timing_from_json(self.ptr, id, c_json.as_ptr()) };
904 if result == 0 {
905 Ok(())
906 } else {
907 Err(get_last_error())
908 }
909 }
910
911 pub fn run_simulation(
912 &self,
913 app_handle: &AppHandle,
914 cancel_flag: &AtomicBool,
915 ) -> Result<SimulationRunOutcome, String> {
916 self.use_hdf5_output()?;
917 self.run_simulation_ex(
918 cancel_flag,
919 Some(simulation_progress_callback),
920 app_handle as *const _ as *mut c_void,
921 None,
922 )
923 }
924
925 pub fn run_vita49_stream(
926 &self,
927 app_handle: &AppHandle,
928 cancel_flag: &AtomicBool,
929 config: &Vita49StreamConfig,
930 telemetry_sender: Option<&Vita49TelemetrySender>,
931 ) -> Result<SimulationRunOutcome, String> {
932 self.configure_vita49_stream(config)?;
933 self.run_simulation_ex(
934 cancel_flag,
935 Some(vita49_lifecycle_callback),
936 app_handle as *const _ as *mut c_void,
937 telemetry_sender,
938 )
939 }
940
941 fn run_simulation_ex(
942 &self,
943 cancel_flag: &AtomicBool,
944 progress_callback: ffi::fers_progress_callback_t,
945 progress_user_data_ptr: *mut c_void,
946 telemetry_sender: Option<&Vita49TelemetrySender>,
947 ) -> Result<SimulationRunOutcome, String> {
948 let cancel_user_data_ptr = cancel_flag as *const _ as *mut c_void;
949 let telemetry_user_data_ptr = telemetry_sender
950 .map(|sender| sender as *const _ as *mut c_void)
951 .unwrap_or(std::ptr::null_mut());
952 let telemetry_callback: ffi::fers_vita49_telemetry_callback_t =
953 if telemetry_sender.is_some() { Some(vita49_telemetry_callback) } else { None };
954
955 let result = unsafe {
956 ffi::fers_run_simulation_ex(
957 self.ptr,
958 progress_callback,
959 progress_user_data_ptr,
960 Some(simulation_cancel_callback),
961 cancel_user_data_ptr,
962 telemetry_callback,
963 telemetry_user_data_ptr,
964 )
965 };
966
967 match result {
968 0 => self.get_last_output_metadata_json().map(SimulationRunOutcome::Completed),
969 FERS_RUN_CANCELLED => {
970 let metadata =
971 self.get_last_output_metadata_json().unwrap_or_else(|_| "{}".to_string());
972 Ok(SimulationRunOutcome::Cancelled(metadata))
973 }
974 _ => Err(get_last_error()),
975 }
976 }
977
978 /// Retrieves JSON metadata for the most recent simulation output files.
979 pub fn get_last_output_metadata_json(&self) -> Result<String, String> {
980 // SAFETY: We pass a valid context pointer. The returned C string is owned by the caller.
981 let metadata_ptr = unsafe { ffi::fers_get_last_output_metadata_json(self.ptr) };
982 if metadata_ptr.is_null() {
983 return Err(get_last_error());
984 }
985 FersOwnedString(metadata_ptr).into_string().map_err(|e| e.to_string())
986 }
987
988 /// Generates a KML file for the current scenario.
989 ///
990 /// # Parameters
991 ///
992 /// * `output_path` - The path where the KML file will be saved.
993 ///
994 /// # Returns
995 ///
996 /// * `Ok(())` - If the KML file was generated successfully.
997 /// * `Err(String)` - If KML generation failed.
998 pub fn generate_kml(&self, output_path: &str) -> Result<(), String> {
999 let c_output_path = CString::new(output_path).map_err(|e| e.to_string())?;
1000 // SAFETY: We pass a valid context pointer and a null-terminated C string for the path.
1001 let result = unsafe { ffi::fers_generate_kml(self.ptr, c_output_path.as_ptr()) };
1002 if result == 0 {
1003 Ok(())
1004 } else {
1005 Err(get_last_error())
1006 }
1007 }
1008
1009 /// Retrieves a sampled gain pattern for a specified antenna.
1010 ///
1011 /// # Parameters
1012 ///
1013 /// * `antenna_id` - The ID of the antenna asset to sample.
1014 /// * `az_samples` - The resolution along the azimuth axis.
1015 /// * `el_samples` - The resolution along the elevation axis.
1016 /// * `frequency` - The frequency in Hz to use for calculation.
1017 ///
1018 /// # Returns
1019 ///
1020 /// * `Ok(AntennaPatternData)` - If the pattern was successfully sampled.
1021 /// * `Err(String)` - If the antenna was not found or an error occurred.
1022 pub fn get_antenna_pattern(
1023 &self,
1024 antenna_id: &str,
1025 az_samples: usize,
1026 el_samples: usize,
1027 frequency: f64,
1028 ) -> Result<AntennaPatternData, String> {
1029 let antenna_id_val = antenna_id
1030 .parse::<u64>()
1031 .map_err(|e| format!("Invalid antenna ID '{antenna_id}': {e}"))?;
1032 // SAFETY: We pass a valid context pointer and valid arguments.
1033 let result_ptr = unsafe {
1034 ffi::fers_get_antenna_pattern(
1035 self.ptr,
1036 antenna_id_val,
1037 az_samples,
1038 el_samples,
1039 frequency,
1040 )
1041 };
1042
1043 if result_ptr.is_null() {
1044 return Err(get_last_error());
1045 }
1046
1047 let owned_data = FersAntennaPatternData(result_ptr);
1048
1049 // SAFETY: Dereferencing the non-null pointer returned by the FFI.
1050 // The data is valid for the lifetime of `owned_data`.
1051 let (gains_ptr, az_count, el_count, max_gain) = unsafe {
1052 (
1053 (*owned_data.0).gains,
1054 (*owned_data.0).az_count,
1055 (*owned_data.0).el_count,
1056 (*owned_data.0).max_gain,
1057 )
1058 };
1059 let total_samples = az_count * el_count;
1060
1061 // SAFETY: The gains_ptr is valid for `total_samples` elements.
1062 let gains_slice = unsafe { std::slice::from_raw_parts(gains_ptr, total_samples) };
1063
1064 Ok(AntennaPatternData { gains: gains_slice.to_vec(), az_count, el_count, max_gain })
1065 }
1066
1067 pub fn calculate_preview_links(&self, time: f64) -> Result<Vec<VisualLink>, String> {
1068 let list_ptr = unsafe { ffi::fers_calculate_preview_links(self.ptr, time) };
1069 if list_ptr.is_null() {
1070 let err_msg = get_last_error();
1071 // If we receive a NULL ptr but no error message, it might be a logic error
1072 // or an unhandled edge case in C++. We default to the retrieved message.
1073 return Err(err_msg);
1074 }
1075
1076 let owned_list = FersVisualLinkList(list_ptr);
1077 let count = unsafe { (*owned_list.0).count };
1078 let links_ptr = unsafe { (*owned_list.0).links };
1079
1080 let mut result = Vec::with_capacity(count);
1081 if count > 0 && !links_ptr.is_null() {
1082 let slice = unsafe { std::slice::from_raw_parts(links_ptr, count) };
1083 for l in slice {
1084 let link_type_val = match l.type_ {
1085 ffi::fers_link_type_t_FERS_LINK_MONOSTATIC => 0,
1086 ffi::fers_link_type_t_FERS_LINK_BISTATIC_TX_TGT => 1,
1087 ffi::fers_link_type_t_FERS_LINK_BISTATIC_TGT_RX => 2,
1088 ffi::fers_link_type_t_FERS_LINK_DIRECT_TX_RX => 3,
1089 _ => 0,
1090 };
1091
1092 let quality_val = match l.quality {
1093 ffi::fers_link_quality_t_FERS_LINK_STRONG => 0,
1094 _ => 1,
1095 };
1096
1097 let label =
1098 unsafe { CStr::from_ptr(l.label.as_ptr()) }.to_string_lossy().into_owned();
1099 let source_id = l.source_id.to_string();
1100 let dest_id = l.dest_id.to_string();
1101 let origin_id = l.origin_id.to_string();
1102
1103 result.push(VisualLink {
1104 link_type: link_type_val,
1105 quality: quality_val,
1106 label,
1107 source_id,
1108 dest_id,
1109 origin_id,
1110 rcs: l.rcs,
1111 actual_power_dbm: l.actual_power_dbm,
1112 display_value: l.display_value,
1113 });
1114 }
1115 }
1116 Ok(result)
1117 }
1118}
1119
1120/// A safe wrapper for the stateless `fers_get_interpolated_motion_path` C-API function.
1121///
1122/// This function converts Rust-native waypoint data into C-compatible types,
1123/// calls the FFI function, and then converts the result back into a `Vec` of points,
1124/// ensuring that all C-allocated memory is properly freed.
1125///
1126/// # Parameters
1127/// * `waypoints` - A vector of motion waypoints from the frontend.
1128/// * `interp_type` - The interpolation algorithm to use.
1129/// * `num_points` - The desired number of points in the output path.
1130///
1131/// # Returns
1132/// * `Ok(Vec<InterpolatedPoint>)` - A vector of points representing the calculated path.
1133/// * `Err(String)` - An error message if the FFI call failed.
1134pub fn get_interpolated_motion_path(
1135 waypoints: Vec<crate::MotionWaypoint>,
1136 interp_type: crate::InterpolationType,
1137 num_points: usize,
1138) -> Result<Vec<crate::InterpolatedMotionPoint>, String> {
1139 if waypoints.is_empty() || num_points == 0 {
1140 return Ok(Vec::new());
1141 }
1142
1143 let c_waypoints: Vec<ffi::fers_motion_waypoint_t> = waypoints
1144 .into_iter()
1145 .map(|wp| ffi::fers_motion_waypoint_t { time: wp.time, x: wp.x, y: wp.y, z: wp.altitude })
1146 .collect();
1147
1148 let c_interp_type = match interp_type {
1149 crate::InterpolationType::Static => ffi::fers_interp_type_t_FERS_INTERP_STATIC,
1150 crate::InterpolationType::Linear => ffi::fers_interp_type_t_FERS_INTERP_LINEAR,
1151 crate::InterpolationType::Cubic => ffi::fers_interp_type_t_FERS_INTERP_CUBIC,
1152 };
1153
1154 // SAFETY: We are calling the stateless FFI function with valid, well-formed arguments.
1155 // The pointer returned is owned by us and must be freed.
1156 let result_ptr = unsafe {
1157 ffi::fers_get_interpolated_motion_path(
1158 c_waypoints.as_ptr(),
1159 c_waypoints.len(),
1160 c_interp_type,
1161 num_points,
1162 )
1163 };
1164
1165 if result_ptr.is_null() {
1166 return Err(get_last_error());
1167 }
1168
1169 // RAII wrapper to ensure the C-allocated path is freed.
1170 struct FersInterpolatedMotionPath(*mut ffi::fers_interpolated_path_t);
1171
1172 impl Drop for FersInterpolatedMotionPath {
1173 fn drop(&mut self) {
1174 if !self.0.is_null() {
1175 // SAFETY: The pointer is valid and owned by this struct.
1176 unsafe { ffi::fers_free_interpolated_motion_path(self.0) };
1177 }
1178 }
1179 }
1180
1181 let owned_path = FersInterpolatedMotionPath(result_ptr);
1182
1183 // SAFETY: We are accessing the fields of a non-null pointer returned by the FFI.
1184 // The `count` and `points` fields are guaranteed to be valid for the lifetime of `owned_path`.
1185 let result_slice =
1186 unsafe { std::slice::from_raw_parts((*owned_path.0).points, (*owned_path.0).count) };
1187
1188 let points: Vec<crate::InterpolatedMotionPoint> = result_slice
1189 .iter()
1190 .map(|p| crate::InterpolatedMotionPoint {
1191 x: p.x,
1192 y: p.y,
1193 z: p.z,
1194 vx: p.vx,
1195 vy: p.vy,
1196 vz: p.vz,
1197 })
1198 .collect();
1199
1200 Ok(points)
1201}
1202
1203/// A safe wrapper for the stateless `fers_get_interpolated_rotation_path` C-API function.
1204///
1205/// This function converts Rust-native rotation waypoints into C-compatible types,
1206/// calls the FFI function, and converts the result back into a `Vec` of points.
1207/// It handles the mapping between Rust enums and C enums for interpolation types
1208/// and ensures that the C-allocated array is properly freed.
1209///
1210/// # Parameters
1211/// * `waypoints` - A vector of `RotationWaypoint`s.
1212/// * `interp_type` - The interpolation algorithm to use.
1213/// * `num_points` - The desired number of points in the output path.
1214///
1215/// # Returns
1216/// * `Ok(Vec<InterpolatedRotationPoint>)` - A vector of interpolated points.
1217/// * `Err(String)` - An error message if the FFI call failed.
1218pub fn get_interpolated_rotation_path(
1219 waypoints: Vec<crate::RotationWaypoint>,
1220 interp_type: crate::InterpolationType,
1221 angle_unit: crate::RotationAngleUnit,
1222 num_points: usize,
1223) -> Result<Vec<crate::InterpolatedRotationPoint>, String> {
1224 if waypoints.is_empty() || num_points == 0 {
1225 return Ok(Vec::new());
1226 }
1227
1228 let c_waypoints: Vec<ffi::fers_rotation_waypoint_t> = waypoints
1229 .into_iter()
1230 .map(|wp| ffi::fers_rotation_waypoint_t {
1231 time: wp.time,
1232 azimuth: wp.azimuth,
1233 elevation: wp.elevation,
1234 })
1235 .collect();
1236
1237 let c_interp_type = match interp_type {
1238 crate::InterpolationType::Static => ffi::fers_interp_type_t_FERS_INTERP_STATIC,
1239 crate::InterpolationType::Linear => ffi::fers_interp_type_t_FERS_INTERP_LINEAR,
1240 crate::InterpolationType::Cubic => ffi::fers_interp_type_t_FERS_INTERP_CUBIC,
1241 };
1242 let c_angle_unit = match angle_unit {
1243 crate::RotationAngleUnit::Deg => ffi::fers_angle_unit_t_FERS_ANGLE_UNIT_DEG,
1244 crate::RotationAngleUnit::Rad => ffi::fers_angle_unit_t_FERS_ANGLE_UNIT_RAD,
1245 };
1246
1247 let result_ptr = unsafe {
1248 ffi::fers_get_interpolated_rotation_path(
1249 c_waypoints.as_ptr(),
1250 c_waypoints.len(),
1251 c_interp_type,
1252 c_angle_unit,
1253 num_points,
1254 )
1255 };
1256
1257 if result_ptr.is_null() {
1258 return Err(get_last_error());
1259 }
1260
1261 struct FersInterpolatedRotationPath(*mut ffi::fers_interpolated_rotation_path_t);
1262
1263 impl Drop for FersInterpolatedRotationPath {
1264 fn drop(&mut self) {
1265 if !self.0.is_null() {
1266 unsafe { ffi::fers_free_interpolated_rotation_path(self.0) };
1267 }
1268 }
1269 }
1270
1271 let owned_path = FersInterpolatedRotationPath(result_ptr);
1272
1273 let result_slice =
1274 unsafe { std::slice::from_raw_parts((*owned_path.0).points, (*owned_path.0).count) };
1275
1276 let points: Vec<crate::InterpolatedRotationPoint> = result_slice
1277 .iter()
1278 .map(|p| crate::InterpolatedRotationPoint { azimuth: p.azimuth, elevation: p.elevation })
1279 .collect();
1280
1281 Ok(points)
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use std::io::Write;
1288 use tempfile::NamedTempFile;
1289
1290 /// A minimal, valid FERS JSON scenario that the core engine can parse.
1291 fn minimal_valid_json() -> &'static str {
1292 r#"{
1293 "simulation": {
1294 "parameters": {
1295 "starttime": 0.0,
1296 "endtime": 1.0,
1297 "rate": 1000.0,
1298 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1299 "coordinatesystem": { "frame": "ENU" }
1300 }
1301 }
1302 }"#
1303 }
1304
1305 /// A scenario with assets and platforms for testing granular updates.
1306 fn scenario_with_assets_json() -> &'static str {
1307 r#"{
1308 "simulation": {
1309 "parameters": {
1310 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1311 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1312 "coordinatesystem": { "frame": "ENU" }
1313 },
1314 "waveforms": [
1315 { "id": 10, "name": "w1", "power": 1000.0, "carrier_frequency": 1e9, "cw": {} }
1316 ],
1317 "antennas": [
1318 { "id": 20, "name": "a1", "pattern": "isotropic" }
1319 ],
1320 "platforms": [
1321 {
1322 "id": 100, "name": "tx_plat",
1323 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 0.0, "y": 0.0, "altitude": 0.0 } ] },
1324 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1325 "components": []
1326 }
1327 ]
1328 }
1329 }"#
1330 }
1331
1332 #[test]
1333 fn test_context_creation_and_destruction() {
1334 // Implicitly tests FersContext::new() and Drop (via fers_context_destroy)
1335 let context = FersContext::new();
1336 assert!(context.is_some(), "Failed to create FersContext via FFI");
1337 }
1338
1339 #[test]
1340 fn test_update_scenario_from_invalid_json() {
1341 let context = FersContext::new().unwrap();
1342 let result = context.update_scenario_from_json("{ malformed_json...");
1343 assert!(result.is_err());
1344 let err_msg = result.unwrap_err();
1345 assert!(
1346 err_msg.contains("JSON parsing/deserialization error"),
1347 "Unexpected error message: {}",
1348 err_msg
1349 );
1350 }
1351
1352 #[test]
1353 fn test_update_and_get_scenario() {
1354 let context = FersContext::new().unwrap();
1355 let result = context.update_scenario_from_json(minimal_valid_json());
1356 assert!(result.is_ok(), "Failed to update scenario from valid JSON");
1357
1358 // Ensure retrieving it back as JSON works and memory is managed
1359 let json_back = context.get_scenario_as_json().unwrap();
1360 assert!(json_back.contains("parameters"), "JSON export missing expected data");
1361
1362 // Ensure retrieving it back as XML works
1363 let xml_back = context.get_scenario_as_xml().unwrap();
1364 assert!(xml_back.contains("<simulation"), "XML export missing root element");
1365 }
1366
1367 #[test]
1368 fn test_update_scenario_returns_rotation_unit_warnings() {
1369 let context = FersContext::new().unwrap();
1370 let mut scenario: serde_json::Value =
1371 serde_json::from_str(scenario_with_assets_json()).unwrap();
1372 scenario["simulation"]["parameters"]["rotationangleunit"] =
1373 serde_json::Value::String("rad".into());
1374 scenario["simulation"]["platforms"][0]["rotationpath"]["rotationwaypoints"][0]["azimuth"] =
1375 serde_json::Value::from(90.0);
1376
1377 let warnings = context
1378 .update_scenario_from_json(&scenario.to_string())
1379 .expect("Scenario update with warnings should still succeed");
1380
1381 assert_eq!(warnings.len(), 1);
1382 assert!(warnings[0].contains("rotation waypoint 0"));
1383 assert!(warnings[0].contains("'azimuth'"));
1384 }
1385
1386 #[test]
1387 fn test_load_scenario_from_xml_file() {
1388 let context = FersContext::new().unwrap();
1389 let mut temp_file = NamedTempFile::new().unwrap();
1390 let minimal_xml = r#"<simulation name="TestAPI"><parameters><starttime>0</starttime><endtime>1</endtime><rate>1000</rate><origin latitude="0" longitude="0" altitude="0"/><coordinatesystem frame="ENU"/></parameters><antenna name="api_iso" pattern="isotropic"/></simulation>"#;
1391 temp_file.write_all(minimal_xml.as_bytes()).unwrap();
1392
1393 let result = context.load_scenario_from_xml_file(temp_file.path().to_str().unwrap());
1394 assert!(result.is_ok(), "Failed to load minimal valid XML from file");
1395 }
1396
1397 #[test]
1398 fn test_generate_kml() {
1399 let context = FersContext::new().unwrap();
1400 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1401
1402 let temp_kml = NamedTempFile::new().unwrap();
1403 let result = context.generate_kml(temp_kml.path().to_str().unwrap());
1404 assert!(result.is_ok(), "KML generation failed");
1405
1406 // Verify data was actually written to the file by the C++ core
1407 let metadata = std::fs::metadata(temp_kml.path()).unwrap();
1408 assert!(metadata.len() > 0, "KML file was created but is empty");
1409 }
1410
1411 #[test]
1412 fn test_get_interpolated_motion_path() {
1413 let waypoints = vec![
1414 crate::MotionWaypoint { time: 0.0, x: 0.0, y: 0.0, altitude: 0.0 },
1415 crate::MotionWaypoint { time: 10.0, x: 10.0, y: 20.0, altitude: 30.0 },
1416 ];
1417
1418 let points =
1419 get_interpolated_motion_path(waypoints, crate::InterpolationType::Linear, 3).unwrap();
1420
1421 assert_eq!(points.len(), 3);
1422 // Linear interpolation midpoint logic check
1423 assert_eq!(points[1].x, 5.0);
1424 assert_eq!(points[1].y, 10.0);
1425 assert_eq!(points[1].z, 15.0);
1426 assert_eq!(points[1].vx, 1.0);
1427 assert_eq!(points[1].vy, 2.0);
1428 assert_eq!(points[1].vz, 3.0);
1429 }
1430
1431 #[test]
1432 fn test_get_interpolated_rotation_path() {
1433 let waypoints = vec![
1434 crate::RotationWaypoint { time: 0.0, azimuth: 0.0, elevation: 0.0 },
1435 crate::RotationWaypoint { time: 10.0, azimuth: 90.0, elevation: 20.0 },
1436 ];
1437
1438 let points = get_interpolated_rotation_path(
1439 waypoints,
1440 crate::InterpolationType::Linear,
1441 crate::RotationAngleUnit::Deg,
1442 3,
1443 )
1444 .unwrap();
1445
1446 assert_eq!(points.len(), 3);
1447 // Linear interpolation midpoint logic check
1448 assert_eq!(points[1].azimuth, 45.0);
1449 assert_eq!(points[1].elevation, 10.0);
1450 }
1451
1452 #[test]
1453 fn test_get_antenna_pattern_invalid_id() {
1454 let context = FersContext::new().unwrap();
1455 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1456
1457 // The minimal JSON has no antennas, so ID "99" should definitively fail.
1458 let result = context.get_antenna_pattern("99", 10, 10, 1e9);
1459 assert!(result.is_err());
1460 assert!(result.unwrap_err().contains("not found"));
1461 }
1462
1463 #[test]
1464 fn test_calculate_preview_links_empty() {
1465 let context = FersContext::new().unwrap();
1466 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1467
1468 // Run on an empty scenario without platforms, shouldn't crash, should return empty vec.
1469 let links = context.calculate_preview_links(0.0).unwrap();
1470 assert_eq!(links.len(), 0);
1471 }
1472 #[test]
1473 fn test_fers_owned_string_null() {
1474 // Test that a null pointer safely converts to an empty string
1475 let null_str = FersOwnedString(std::ptr::null_mut());
1476 let result = null_str.into_string();
1477 assert_eq!(result.unwrap(), "");
1478
1479 // Test that dropping a null pointer doesn't panic or segfault
1480 let drop_str = FersOwnedString(std::ptr::null_mut());
1481 drop(drop_str);
1482 }
1483
1484 #[test]
1485 fn test_raii_null_drops() {
1486 // Test that dropping null pointers in our internal RAII wrappers is safe
1487 let ant_data = FersAntennaPatternData(std::ptr::null_mut());
1488 drop(ant_data);
1489
1490 let link_list = FersVisualLinkList(std::ptr::null_mut());
1491 drop(link_list);
1492 }
1493
1494 #[test]
1495 fn test_get_interpolated_paths_empty() {
1496 // Test empty waypoints
1497 let motion =
1498 get_interpolated_motion_path(vec![], crate::InterpolationType::Linear, 5).unwrap();
1499 assert!(motion.is_empty());
1500
1501 let rotation = get_interpolated_rotation_path(
1502 vec![],
1503 crate::InterpolationType::Linear,
1504 crate::RotationAngleUnit::Deg,
1505 5,
1506 )
1507 .unwrap();
1508 assert!(rotation.is_empty());
1509
1510 // Test zero num_points
1511 let wp_m = vec![crate::MotionWaypoint { time: 0.0, x: 0.0, y: 0.0, altitude: 0.0 }];
1512 let motion2 =
1513 get_interpolated_motion_path(wp_m, crate::InterpolationType::Linear, 0).unwrap();
1514 assert!(motion2.is_empty());
1515
1516 let wp_r = vec![crate::RotationWaypoint { time: 0.0, azimuth: 0.0, elevation: 0.0 }];
1517 let rotation2 = get_interpolated_rotation_path(
1518 wp_r,
1519 crate::InterpolationType::Linear,
1520 crate::RotationAngleUnit::Deg,
1521 0,
1522 )
1523 .unwrap();
1524 assert!(rotation2.is_empty());
1525 }
1526
1527 #[test]
1528 fn test_granular_updates() {
1529 let context = FersContext::new().unwrap();
1530 context.update_scenario_from_json(scenario_with_assets_json()).unwrap();
1531
1532 // Platform update
1533 let plat_json = r#"{
1534 "id": 100,
1535 "name": "UpdatedPlatform",
1536 "motionpath": { "interpolation": "static", "positionwaypoints": [] },
1537 "rotationpath": { "interpolation": "static", "rotationwaypoints": [] }
1538 }"#;
1539 let result = context.update_platform_from_json("100", plat_json);
1540 assert!(result.is_ok(), "Failed to update platform: {:?}", result.err());
1541
1542 // Antenna update
1543 let ant_json =
1544 r#"{ "id": 20, "name": "UpdatedAntenna", "pattern": "isotropic", "efficiency": 0.5 }"#;
1545 let result = context.update_antenna_from_json(ant_json);
1546 assert!(result.is_ok(), "Failed to update antenna: {:?}", result.err());
1547
1548 // Waveform update
1549 let wf_json = r#"{ "id": 10, "name": "UpdatedWaveform", "power": 500.0, "carrier_frequency": 1e9, "cw": {} }"#;
1550 let result = context.update_waveform_from_json(wf_json);
1551 assert!(result.is_ok(), "Failed to update waveform: {:?}", result.err());
1552
1553 // Verify changes
1554 let updated_scenario = context.get_scenario_as_json().unwrap();
1555 assert!(updated_scenario.contains("UpdatedPlatform"));
1556 assert!(updated_scenario.contains("UpdatedAntenna"));
1557 assert!(updated_scenario.contains("UpdatedWaveform"));
1558 }
1559
1560 #[test]
1561 fn test_granular_updates_invalid() {
1562 let context = FersContext::new().unwrap();
1563 context.update_scenario_from_json(scenario_with_assets_json()).unwrap();
1564
1565 assert!(context.update_platform_from_json("999", "{}").is_err());
1566 assert!(context.update_antenna_from_json("{bad").is_err());
1567 assert!(context.update_waveform_from_json("{bad").is_err());
1568 }
1569
1570 #[test]
1571 fn test_get_antenna_pattern_success() {
1572 let context = FersContext::new().unwrap();
1573 let scenario = r#"{
1574 "simulation": {
1575 "parameters": {
1576 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1577 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1578 "coordinatesystem": { "frame": "ENU" }
1579 },
1580 "antennas": [
1581 { "id": 1, "name": "iso", "pattern": "isotropic" }
1582 ]
1583 }
1584 }"#;
1585 context.update_scenario_from_json(scenario).unwrap();
1586
1587 let pattern = context.get_antenna_pattern("1", 4, 4, 1e9).unwrap();
1588 assert_eq!(pattern.az_count, 4);
1589 assert_eq!(pattern.el_count, 4);
1590 assert_eq!(pattern.gains.len(), 16);
1591 assert!(pattern.max_gain > 0.0);
1592 }
1593
1594 #[test]
1595 fn test_calculate_preview_links_success() {
1596 let context = FersContext::new().unwrap();
1597 let scenario = r#"{
1598 "simulation": {
1599 "parameters": {
1600 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1601 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1602 "coordinatesystem": { "frame": "ENU" }
1603 },
1604 "waveforms": [
1605 { "id": 10, "name": "w1", "power": 1000.0, "carrier_frequency": 1e9, "cw": {} }
1606 ],
1607 "antennas": [
1608 { "id": 20, "name": "a1", "pattern": "isotropic" }
1609 ],
1610 "timings": [
1611 { "id": 30, "name": "t1", "frequency": 1e6 }
1612 ],
1613 "platforms": [
1614 {
1615 "id": 100, "name": "tx_plat",
1616 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 0.0, "y": 0.0, "altitude": 0.0 } ] },
1617 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1618 "components": [ { "transmitter": { "id": 101, "name": "tx1", "waveform": 10, "antenna": 20, "timing": 30, "cw_mode": {} } } ]
1619 },
1620 {
1621 "id": 200, "name": "rx_plat",
1622 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 100.0, "y": 0.0, "altitude": 0.0 } ] },
1623 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1624 "components": [ { "receiver": { "id": 201, "name": "rx1", "antenna": 20, "timing": 30, "cw_mode": {}, "noise_temp": 290.0 } } ]
1625 }
1626 ]
1627 }
1628 }"#;
1629 context.update_scenario_from_json(scenario).unwrap();
1630
1631 let links = context.calculate_preview_links(0.0).unwrap();
1632 assert!(!links.is_empty());
1633
1634 let direct_link =
1635 links.iter().find(|l| l.link_type == 3).expect("Expected a direct interference link");
1636 assert_eq!(direct_link.source_id, "101");
1637 assert_eq!(direct_link.dest_id, "201");
1638 assert!(direct_link.display_value > -999.0);
1639 }
1640}