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