Skip to main content

polyscope_rs/
lib.rs

1//! polyscope-rs: A Rust-native 3D visualization library for geometric data.
2//!
3//! Polyscope is a viewer and user interface for 3D data such as meshes and point clouds.
4//! It allows you to register your data and quickly generate informative visualizations.
5//!
6//! # Quick Start
7//!
8//! ```no_run
9//! use polyscope_rs::*;
10//!
11//! fn main() -> Result<()> {
12//!     // Initialize polyscope
13//!     init()?;
14//!
15//!     // Register a point cloud
16//!     let points = vec![
17//!         Vec3::new(0.0, 0.0, 0.0),
18//!         Vec3::new(1.0, 0.0, 0.0),
19//!         Vec3::new(0.0, 1.0, 0.0),
20//!     ];
21//!     register_point_cloud("my points", points);
22//!
23//!     // Show the viewer
24//!     show();
25//!
26//!     Ok(())
27//! }
28//! ```
29//!
30//! # Architecture
31//!
32//! Polyscope uses a paradigm of **structures** and **quantities**:
33//!
34//! - A **structure** is a geometric object in the scene (point cloud, mesh, etc.)
35//! - A **quantity** is data associated with a structure (scalar field, vector field, colors)
36//!
37//! # Structures
38//!
39//! - [`PointCloud`] - A set of points in 3D space
40//! - [`SurfaceMesh`] - A triangular or polygonal mesh
41//! - [`CurveNetwork`] - A network of curves/edges
42//! - [`VolumeMesh`] - A tetrahedral or hexahedral mesh
43//! - [`VolumeGrid`] - A regular grid of values (for implicit surfaces)
44//! - [`CameraView`] - A camera frustum visualization
45
46// Type casts in visualization code: Conversions between coordinate types (f32, f64)
47// and index types (u32, usize) are intentional. Values are bounded by practical
48// limits (screen resolution, mesh sizes).
49#![allow(clippy::cast_possible_truncation)]
50#![allow(clippy::cast_sign_loss)]
51#![allow(clippy::cast_precision_loss)]
52// Documentation lints: Detailed error/panic docs will be added as the API stabilizes.
53#![allow(clippy::missing_errors_doc)]
54#![allow(clippy::missing_panics_doc)]
55// Function length: Event handling and application logic are legitimately complex.
56#![allow(clippy::too_many_lines)]
57// Code organization: Local types in event handlers improve readability.
58#![allow(clippy::items_after_statements)]
59// Function signatures: Some public API functions need many parameters for flexibility.
60#![allow(clippy::too_many_arguments)]
61// Method design: Some methods take &self for API consistency or future expansion.
62#![allow(clippy::unused_self)]
63// Argument design: Some functions take ownership for API consistency.
64#![allow(clippy::needless_pass_by_value)]
65// Variable naming: Short names (x, y, z) are clear in context.
66#![allow(clippy::many_single_char_names)]
67// Configuration structs may have many boolean fields.
68#![allow(clippy::struct_excessive_bools)]
69// #[must_use] design: Setter methods intentionally omit #[must_use] since
70// the mutation happens in-place; the &Self return is just for chaining convenience.
71#![allow(clippy::must_use_candidate)]
72// Documentation formatting: Backtick linting is too strict for doc comments.
73#![allow(clippy::doc_markdown)]
74
75/// Generates `get_<prefix>`, `with_<prefix>`, and `with_<prefix>_ref` accessor functions
76/// for a structure type registered in the global context.
77macro_rules! impl_structure_accessors {
78    (
79        get_fn = $get_fn:ident,
80        with_fn = $with_fn:ident,
81        with_ref_fn = $with_ref_fn:ident,
82        handle = $handle:ident,
83        type_name = $type_name:expr,
84        rust_type = $rust_type:ty,
85        doc_name = $doc_name:expr
86    ) => {
87        #[doc = concat!("Gets a registered ", $doc_name, " by name.")]
88        #[must_use]
89        pub fn $get_fn(name: &str) -> Option<$handle> {
90            crate::with_context(|ctx| {
91                if ctx.registry.contains($type_name, name) {
92                    Some($handle {
93                        name: name.to_string(),
94                    })
95                } else {
96                    None
97                }
98            })
99        }
100
101        #[doc = concat!("Executes a closure with mutable access to a registered ", $doc_name, ".\n\nReturns `None` if the ", $doc_name, " does not exist.")]
102        pub fn $with_fn<F, R>(name: &str, f: F) -> Option<R>
103        where
104            F: FnOnce(&mut $rust_type) -> R,
105        {
106            crate::with_context_mut(|ctx| {
107                ctx.registry
108                    .get_mut($type_name, name)
109                    .and_then(|s| s.as_any_mut().downcast_mut::<$rust_type>())
110                    .map(f)
111            })
112        }
113
114        #[doc = concat!("Executes a closure with immutable access to a registered ", $doc_name, ".\n\nReturns `None` if the ", $doc_name, " does not exist.")]
115        pub fn $with_ref_fn<F, R>(name: &str, f: F) -> Option<R>
116        where
117            F: FnOnce(&$rust_type) -> R,
118        {
119            crate::with_context(|ctx| {
120                ctx.registry
121                    .get($type_name, name)
122                    .and_then(|s| s.as_any().downcast_ref::<$rust_type>())
123                    .map(f)
124            })
125        }
126    };
127}
128
129mod app;
130mod camera_view;
131mod curve_network;
132mod floating;
133mod gizmo;
134mod groups;
135mod headless;
136mod init;
137mod point_cloud;
138mod screenshot;
139mod slice_plane;
140mod surface_mesh;
141mod transform;
142mod ui_sync;
143mod volume_grid;
144mod volume_mesh;
145
146// Re-export core types
147pub use polyscope_core::{
148    Mat4, Vec2, Vec3, Vec4,
149    error::{PolyscopeError, Result},
150    gizmo::{GizmoAxis, GizmoConfig, GizmoMode, GizmoSpace, Transform},
151    group::Group,
152    options::Options,
153    pick::{PickResult, Pickable},
154    quantity::{ParamCoordsType, ParamVizStyle, Quantity, QuantityKind},
155    registry::Registry,
156    slice_plane::{MAX_SLICE_PLANES, SlicePlane, SlicePlaneUniforms},
157    state::{Context, with_context, with_context_mut},
158    structure::{HasQuantities, Structure},
159};
160
161// Re-export render types
162pub use polyscope_render::{
163    AxisDirection, Camera, ColorMap, ColorMapRegistry, Material, MaterialRegistry, NavigationStyle,
164    PickElementType, ProjectionMode, RenderContext, RenderEngine, ScreenshotError,
165    ScreenshotOptions,
166};
167
168// Re-export UI types
169pub use polyscope_ui::{
170    AppearanceSettings, CameraSettings, GizmoAction, GizmoSettings, GroupSettings, GroupsAction,
171    SceneExtents, SelectionInfo, SlicePlaneGizmoAction, SlicePlaneSelectionInfo,
172    SlicePlaneSettings, SlicePlanesAction, ViewAction,
173};
174
175// Re-export structures
176pub use polyscope_structures::volume_grid::VolumeGridVizMode;
177pub use polyscope_structures::{
178    CameraExtrinsics, CameraIntrinsics, CameraParameters, CameraView, CurveNetwork, PointCloud,
179    SurfaceMesh, VolumeCellType, VolumeGrid, VolumeMesh,
180};
181
182// Re-export module APIs
183pub use camera_view::*;
184pub use curve_network::*;
185pub use floating::*;
186pub use gizmo::*;
187pub use groups::*;
188pub use headless::*;
189pub use init::*;
190pub use point_cloud::*;
191pub use screenshot::*;
192pub use slice_plane::*;
193pub use surface_mesh::*;
194pub use transform::*;
195pub use ui_sync::*;
196pub use volume_grid::*;
197pub use volume_mesh::*;
198
199/// Removes a structure by name.
200pub fn remove_structure(name: &str) {
201    with_context_mut(|ctx| {
202        // Try removing from each structure type
203        ctx.registry.remove("PointCloud", name);
204        ctx.registry.remove("SurfaceMesh", name);
205        ctx.registry.remove("CurveNetwork", name);
206        ctx.registry.remove("VolumeMesh", name);
207        ctx.registry.remove("VolumeGrid", name);
208        ctx.registry.remove("CameraView", name);
209        ctx.update_extents();
210    });
211}
212
213/// Removes all structures.
214pub fn remove_all_structures() {
215    with_context_mut(|ctx| {
216        ctx.registry.clear();
217        ctx.update_extents();
218    });
219}
220
221/// Sets a callback that is invoked when files are dropped onto the polyscope window.
222///
223/// The callback receives a slice of file paths that were dropped.
224///
225/// # Example
226/// ```no_run
227/// polyscope_rs::set_file_drop_callback(|paths| {
228///     for path in paths {
229///         println!("Dropped: {}", path.display());
230///     }
231/// });
232/// ```
233pub fn set_file_drop_callback(callback: impl FnMut(&[std::path::PathBuf]) + Send + Sync + 'static) {
234    with_context_mut(|ctx| {
235        ctx.file_drop_callback = Some(Box::new(callback));
236    });
237}
238
239/// Clears the file drop callback.
240pub fn clear_file_drop_callback() {
241    with_context_mut(|ctx| {
242        ctx.file_drop_callback = None;
243    });
244}
245
246/// Loads a blendable (4-channel, RGB-tintable) matcap material from disk.
247///
248/// Takes a name and 4 image file paths for R, G, B, K matcap channels.
249/// The material becomes available in the UI material selector on the next frame.
250///
251/// Supports HDR, JPEG, PNG, EXR, and other image formats.
252///
253/// # Example
254/// ```no_run
255/// polyscope_rs::load_blendable_material("metal", [
256///     "assets/metal_r.hdr",
257///     "assets/metal_g.hdr",
258///     "assets/metal_b.hdr",
259///     "assets/metal_k.hdr",
260/// ]);
261/// ```
262pub fn load_blendable_material(name: &str, filenames: [&str; 4]) {
263    with_context_mut(|ctx| {
264        ctx.material_load_queue
265            .push(polyscope_core::state::MaterialLoadRequest::Blendable {
266                name: name.to_string(),
267                filenames: [
268                    filenames[0].to_string(),
269                    filenames[1].to_string(),
270                    filenames[2].to_string(),
271                    filenames[3].to_string(),
272                ],
273            });
274    });
275}
276
277/// Loads a blendable material using a base path and extension.
278///
279/// Automatically expands to 4 filenames by appending `_r`, `_g`, `_b`, `_k`
280/// before the extension. For example:
281/// `load_blendable_material_ext("metal", "assets/metal", ".hdr")`
282/// loads `assets/metal_r.hdr`, `assets/metal_g.hdr`, `assets/metal_b.hdr`, `assets/metal_k.hdr`.
283pub fn load_blendable_material_ext(name: &str, base: &str, ext: &str) {
284    load_blendable_material(
285        name,
286        [
287            &format!("{base}_r{ext}"),
288            &format!("{base}_g{ext}"),
289            &format!("{base}_b{ext}"),
290            &format!("{base}_k{ext}"),
291        ],
292    );
293}
294
295/// Loads a static (single-texture, non-RGB-tintable) matcap material from disk.
296///
297/// The same texture is used for all 4 matcap channels. Static materials
298/// cannot be tinted with per-surface RGB colors.
299///
300/// # Example
301/// ```no_run
302/// polyscope_rs::load_static_material("stone", "assets/stone.jpg");
303/// ```
304pub fn load_static_material(name: &str, filename: &str) {
305    with_context_mut(|ctx| {
306        ctx.material_load_queue
307            .push(polyscope_core::state::MaterialLoadRequest::Static {
308                name: name.to_string(),
309                path: filename.to_string(),
310            });
311    });
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use std::sync::atomic::{AtomicU32, Ordering};
318
319    // Counter for unique test names to avoid race conditions
320    static COUNTER: AtomicU32 = AtomicU32::new(0);
321
322    fn unique_name(prefix: &str) -> String {
323        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
324        format!("{prefix}_{n}")
325    }
326
327    fn setup() {
328        // Initialize context (only once)
329        // Use ok() to handle race conditions in parallel tests
330        let _ = init();
331    }
332
333    #[test]
334    fn test_register_curve_network() {
335        setup();
336        let name = unique_name("test_cn");
337        let nodes = vec![
338            Vec3::new(0.0, 0.0, 0.0),
339            Vec3::new(1.0, 0.0, 0.0),
340            Vec3::new(1.0, 1.0, 0.0),
341        ];
342        let edges = vec![[0, 1], [1, 2]];
343
344        let handle = register_curve_network(&name, nodes, edges);
345        assert_eq!(handle.name(), name);
346
347        // Verify it's retrievable
348        let found = get_curve_network(&name);
349        assert!(found.is_some());
350
351        // Verify non-existent returns None
352        let not_found = get_curve_network("nonexistent_xyz_123");
353        assert!(not_found.is_none());
354    }
355
356    #[test]
357    fn test_register_curve_network_line() {
358        setup();
359        let name = unique_name("line");
360        let nodes = vec![
361            Vec3::new(0.0, 0.0, 0.0),
362            Vec3::new(1.0, 0.0, 0.0),
363            Vec3::new(2.0, 0.0, 0.0),
364            Vec3::new(3.0, 0.0, 0.0),
365        ];
366
367        register_curve_network_line(&name, nodes);
368
369        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
370        assert_eq!(num_edges, Some(3)); // 0-1, 1-2, 2-3
371    }
372
373    #[test]
374    fn test_register_curve_network_loop() {
375        setup();
376        let name = unique_name("loop");
377        let nodes = vec![
378            Vec3::new(0.0, 0.0, 0.0),
379            Vec3::new(1.0, 0.0, 0.0),
380            Vec3::new(1.0, 1.0, 0.0),
381        ];
382
383        register_curve_network_loop(&name, nodes);
384
385        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
386        assert_eq!(num_edges, Some(3)); // 0-1, 1-2, 2-0
387    }
388
389    #[test]
390    fn test_register_curve_network_segments() {
391        setup();
392        let name = unique_name("segs");
393        let nodes = vec![
394            Vec3::new(0.0, 0.0, 0.0),
395            Vec3::new(1.0, 0.0, 0.0),
396            Vec3::new(2.0, 0.0, 0.0),
397            Vec3::new(3.0, 0.0, 0.0),
398        ];
399
400        register_curve_network_segments(&name, nodes);
401
402        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
403        assert_eq!(num_edges, Some(2)); // 0-1, 2-3
404    }
405
406    #[test]
407    fn test_curve_network_handle_methods() {
408        setup();
409        let name = unique_name("handle_test");
410        let nodes = vec![Vec3::ZERO, Vec3::X];
411        let edges = vec![[0, 1]];
412
413        let handle = register_curve_network(&name, nodes, edges);
414
415        // Test chained setters
416        handle
417            .set_color(Vec3::new(1.0, 0.0, 0.0))
418            .set_radius(0.1, false)
419            .set_material("clay");
420
421        // Verify values were set
422        with_curve_network_ref(&name, |cn| {
423            assert_eq!(cn.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
424            assert_eq!(cn.radius(), 0.1);
425            assert!(!cn.radius_is_relative());
426            assert_eq!(cn.material(), "clay");
427        });
428    }
429
430    #[test]
431    fn test_with_curve_network() {
432        setup();
433        let name = unique_name("with_test");
434        let nodes = vec![Vec3::ZERO, Vec3::X, Vec3::Y];
435        let edges = vec![[0, 1], [1, 2]];
436
437        register_curve_network(&name, nodes, edges);
438
439        // Test mutable access
440        let result = with_curve_network(&name, |cn| {
441            cn.set_color(Vec3::new(0.5, 0.5, 0.5));
442            cn.num_nodes()
443        });
444        assert_eq!(result, Some(3));
445
446        // Verify mutation persisted
447        let color = with_curve_network_ref(&name, |cn| cn.color());
448        assert_eq!(color, Some(Vec4::new(0.5, 0.5, 0.5, 1.0)));
449    }
450
451    #[test]
452    fn test_create_group() {
453        setup();
454        let name = unique_name("test_group");
455        let handle = create_group(&name);
456        assert_eq!(handle.name(), name);
457        assert!(handle.is_enabled());
458    }
459
460    #[test]
461    fn test_get_group() {
462        setup();
463        let name = unique_name("get_group");
464        create_group(&name);
465
466        let found = get_group(&name);
467        assert!(found.is_some());
468        assert_eq!(found.unwrap().name(), name);
469
470        let not_found = get_group("nonexistent_group_xyz");
471        assert!(not_found.is_none());
472    }
473
474    #[test]
475    fn test_group_enable_disable() {
476        setup();
477        let name = unique_name("enable_group");
478        let handle = create_group(&name);
479
480        assert!(handle.is_enabled());
481        handle.set_enabled(false);
482        assert!(!handle.is_enabled());
483        handle.set_enabled(true);
484        assert!(handle.is_enabled());
485    }
486
487    #[test]
488    fn test_group_add_structures() {
489        setup();
490        let group_name = unique_name("struct_group");
491        let pc_name = unique_name("pc_in_group");
492
493        // Create point cloud
494        register_point_cloud(&pc_name, vec![Vec3::ZERO, Vec3::X]);
495
496        // Create group and add point cloud
497        let handle = create_group(&group_name);
498        handle.add_point_cloud(&pc_name);
499
500        assert_eq!(handle.num_structures(), 1);
501    }
502
503    #[test]
504    fn test_group_hierarchy() {
505        setup();
506        let parent_name = unique_name("parent_group");
507        let child_name = unique_name("child_group");
508
509        let parent = create_group(&parent_name);
510        let _child = create_group(&child_name);
511
512        parent.add_child_group(&child_name);
513
514        assert_eq!(parent.num_child_groups(), 1);
515    }
516
517    #[test]
518    fn test_remove_group() {
519        setup();
520        let name = unique_name("remove_group");
521        create_group(&name);
522
523        assert!(get_group(&name).is_some());
524        remove_group(&name);
525        assert!(get_group(&name).is_none());
526    }
527
528    #[test]
529    fn test_add_slice_plane() {
530        setup();
531        let name = unique_name("slice_plane");
532        let handle = add_slice_plane(&name);
533        assert_eq!(handle.name(), name);
534        assert!(handle.is_enabled());
535    }
536
537    #[test]
538    fn test_slice_plane_pose() {
539        setup();
540        let name = unique_name("slice_pose");
541        let handle = add_slice_plane_with_pose(&name, Vec3::new(1.0, 2.0, 3.0), Vec3::X);
542
543        assert_eq!(handle.origin(), Vec3::new(1.0, 2.0, 3.0));
544        assert_eq!(handle.normal(), Vec3::X);
545    }
546
547    #[test]
548    fn test_slice_plane_setters() {
549        setup();
550        let name = unique_name("slice_setters");
551        let handle = add_slice_plane(&name);
552
553        handle
554            .set_origin(Vec3::new(1.0, 0.0, 0.0))
555            .set_normal(Vec3::Z)
556            .set_color(Vec3::new(1.0, 0.0, 0.0))
557            .set_transparency(0.5);
558
559        assert_eq!(handle.origin(), Vec3::new(1.0, 0.0, 0.0));
560        assert_eq!(handle.normal(), Vec3::Z);
561        assert_eq!(handle.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
562        assert!((handle.transparency() - 0.5).abs() < 0.001);
563    }
564
565    #[test]
566    fn test_slice_plane_enable_disable() {
567        setup();
568        let name = unique_name("slice_enable");
569        let handle = add_slice_plane(&name);
570
571        assert!(handle.is_enabled());
572        handle.set_enabled(false);
573        assert!(!handle.is_enabled());
574        handle.set_enabled(true);
575        assert!(handle.is_enabled());
576    }
577
578    #[test]
579    fn test_remove_slice_plane() {
580        setup();
581        let name = unique_name("slice_remove");
582        add_slice_plane(&name);
583
584        assert!(get_slice_plane(&name).is_some());
585        remove_slice_plane(&name);
586        assert!(get_slice_plane(&name).is_none());
587    }
588
589    #[test]
590    fn test_select_structure() {
591        setup();
592        let name = unique_name("select_pc");
593        register_point_cloud(&name, vec![Vec3::ZERO]);
594
595        assert!(!has_selection());
596
597        select_structure("PointCloud", &name);
598        assert!(has_selection());
599
600        let selected = get_selected_structure();
601        assert!(selected.is_some());
602        let (type_name, struct_name) = selected.unwrap();
603        assert_eq!(type_name, "PointCloud");
604        assert_eq!(struct_name, name);
605
606        deselect_structure();
607        assert!(!has_selection());
608    }
609
610    #[test]
611    fn test_slice_plane_gizmo_selection() {
612        setup();
613        let name = unique_name("slice_gizmo");
614        add_slice_plane(&name);
615
616        // Initially no slice plane selected
617        let info = get_slice_plane_selection_info();
618        assert!(!info.has_selection);
619
620        // Select slice plane
621        select_slice_plane_for_gizmo(&name);
622        let info = get_slice_plane_selection_info();
623        assert!(info.has_selection);
624        assert_eq!(info.name, name);
625
626        // Deselect slice plane
627        deselect_slice_plane_gizmo();
628        let info = get_slice_plane_selection_info();
629        assert!(!info.has_selection);
630    }
631
632    #[test]
633    fn test_slice_plane_structure_mutual_exclusion() {
634        setup();
635        let pc_name = unique_name("mutual_pc");
636        let plane_name = unique_name("mutual_plane");
637
638        register_point_cloud(&pc_name, vec![Vec3::ZERO]);
639        add_slice_plane(&plane_name);
640
641        // Select structure
642        select_structure("PointCloud", &pc_name);
643        assert!(has_selection());
644
645        // Select slice plane - should deselect structure
646        select_slice_plane_for_gizmo(&plane_name);
647        assert!(!has_selection()); // Structure should be deselected
648        let info = get_slice_plane_selection_info();
649        assert!(info.has_selection);
650
651        // Select structure again - should deselect slice plane
652        select_structure("PointCloud", &pc_name);
653        assert!(has_selection());
654        let info = get_slice_plane_selection_info();
655        assert!(!info.has_selection); // Slice plane should be deselected
656    }
657
658    #[test]
659    fn test_structure_transform() {
660        setup();
661        let name = unique_name("transform_pc");
662        register_point_cloud(&name, vec![Vec3::ZERO, Vec3::X]);
663
664        // Default transform is identity
665        let transform = get_point_cloud_transform(&name);
666        assert!(transform.is_some());
667
668        // Set a translation transform
669        let new_transform = Mat4::from_translation(Vec3::new(1.0, 2.0, 3.0));
670        set_point_cloud_transform(&name, new_transform);
671
672        let transform = get_point_cloud_transform(&name).unwrap();
673        let translation = transform.w_axis.truncate();
674        assert!((translation - Vec3::new(1.0, 2.0, 3.0)).length() < 0.001);
675    }
676
677    #[test]
678    fn test_get_slice_plane_settings() {
679        setup();
680        let name = unique_name("ui_slice_plane");
681
682        // Add a slice plane
683        add_slice_plane_with_pose(&name, Vec3::new(1.0, 2.0, 3.0), Vec3::X);
684
685        // Get settings
686        let settings = get_slice_plane_settings();
687        let found = settings.iter().find(|s| s.name == name);
688        assert!(found.is_some());
689
690        let s = found.unwrap();
691        assert_eq!(s.origin, [1.0, 2.0, 3.0]);
692        assert_eq!(s.normal, [1.0, 0.0, 0.0]);
693        assert!(s.enabled);
694    }
695
696    #[test]
697    fn test_apply_slice_plane_settings() {
698        setup();
699        let name = unique_name("apply_slice_plane");
700
701        // Add a slice plane
702        add_slice_plane(&name);
703
704        // Create modified settings
705        let settings = polyscope_ui::SlicePlaneSettings {
706            name: name.clone(),
707            enabled: false,
708            origin: [5.0, 6.0, 7.0],
709            normal: [0.0, 0.0, 1.0],
710            draw_plane: false,
711            draw_widget: true,
712            color: [1.0, 0.0, 0.0],
713            transparency: 0.8,
714            plane_size: 0.2,
715            is_selected: false,
716        };
717
718        // Apply settings
719        apply_slice_plane_settings(&settings);
720
721        // Verify
722        let handle = get_slice_plane(&name).unwrap();
723        assert!(!handle.is_enabled());
724        assert_eq!(handle.origin(), Vec3::new(5.0, 6.0, 7.0));
725        assert_eq!(handle.normal(), Vec3::Z);
726        assert!(!handle.draw_plane());
727        assert!(handle.draw_widget());
728        assert_eq!(handle.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
729        assert!((handle.transparency() - 0.8).abs() < 0.001);
730    }
731
732    #[test]
733    fn test_handle_slice_plane_action_add() {
734        setup();
735        let name = unique_name("action_add_plane");
736        let mut settings = Vec::new();
737
738        handle_slice_plane_action(
739            polyscope_ui::SlicePlanesAction::Add(name.clone()),
740            &mut settings,
741        );
742
743        assert_eq!(settings.len(), 1);
744        assert_eq!(settings[0].name, name);
745        assert!(get_slice_plane(&name).is_some());
746    }
747
748    #[test]
749    fn test_handle_slice_plane_action_remove() {
750        setup();
751        let name = unique_name("action_remove_plane");
752
753        // Add plane
754        add_slice_plane(&name);
755        let mut settings = vec![polyscope_ui::SlicePlaneSettings::with_name(&name)];
756
757        // Remove via action
758        handle_slice_plane_action(polyscope_ui::SlicePlanesAction::Remove(0), &mut settings);
759
760        assert!(settings.is_empty());
761        assert!(get_slice_plane(&name).is_none());
762    }
763
764    #[test]
765    fn test_get_group_settings() {
766        setup();
767        let name = unique_name("ui_group");
768        let pc_name = unique_name("pc_in_ui_group");
769
770        // Create group and add a structure
771        let handle = create_group(&name);
772        register_point_cloud(&pc_name, vec![Vec3::ZERO]);
773        handle.add_point_cloud(&pc_name);
774
775        // Get settings
776        let settings = get_group_settings();
777        let found = settings.iter().find(|s| s.name == name);
778        assert!(found.is_some());
779
780        let s = found.unwrap();
781        assert!(s.enabled);
782        assert!(s.show_child_details);
783        assert_eq!(s.child_structures.len(), 1);
784        assert_eq!(s.child_structures[0], ("PointCloud".to_string(), pc_name));
785    }
786
787    #[test]
788    fn test_apply_group_settings() {
789        setup();
790        let name = unique_name("apply_group");
791
792        // Create group
793        create_group(&name);
794
795        // Create modified settings
796        let settings = polyscope_ui::GroupSettings {
797            name: name.clone(),
798            enabled: false,
799            show_child_details: false,
800            parent_group: None,
801            child_structures: Vec::new(),
802            child_groups: Vec::new(),
803        };
804
805        // Apply settings
806        apply_group_settings(&settings);
807
808        // Verify
809        let handle = get_group(&name).unwrap();
810        assert!(!handle.is_enabled());
811    }
812
813    #[test]
814    fn test_get_gizmo_settings() {
815        setup();
816
817        // Set known values
818        set_gizmo_space(GizmoSpace::Local);
819        set_gizmo_visible(false);
820        set_gizmo_snap_translate(0.5);
821        set_gizmo_snap_rotate(15.0);
822        set_gizmo_snap_scale(0.1);
823
824        let settings = get_gizmo_settings();
825        assert!(settings.local_space); // Local
826        assert!(!settings.visible);
827        assert!((settings.snap_translate - 0.5).abs() < 0.001);
828        assert!((settings.snap_rotate - 15.0).abs() < 0.001);
829        assert!((settings.snap_scale - 0.1).abs() < 0.001);
830    }
831
832    #[test]
833    fn test_apply_gizmo_settings() {
834        setup();
835
836        let settings = polyscope_ui::GizmoSettings {
837            local_space: false, // World
838            visible: true,
839            snap_translate: 1.0,
840            snap_rotate: 45.0,
841            snap_scale: 0.25,
842        };
843
844        apply_gizmo_settings(&settings);
845
846        assert_eq!(get_gizmo_space(), GizmoSpace::World);
847        assert!(is_gizmo_visible());
848    }
849
850    #[test]
851    fn test_get_selection_info_with_selection() {
852        setup();
853        let name = unique_name("gizmo_select_pc");
854
855        register_point_cloud(&name, vec![Vec3::ZERO]);
856        select_structure("PointCloud", &name);
857
858        let info = get_selection_info();
859        assert!(info.has_selection);
860        assert_eq!(info.type_name, "PointCloud");
861        assert_eq!(info.name, name);
862
863        deselect_structure();
864    }
865
866    #[test]
867    fn test_apply_selection_transform() {
868        setup();
869        let name = unique_name("gizmo_transform_pc");
870
871        register_point_cloud(&name, vec![Vec3::ZERO]);
872        select_structure("PointCloud", &name);
873
874        let selection = polyscope_ui::SelectionInfo {
875            has_selection: true,
876            type_name: "PointCloud".to_string(),
877            name: name.clone(),
878            translation: [1.0, 2.0, 3.0],
879            rotation_degrees: [0.0, 0.0, 0.0],
880            scale: [1.0, 1.0, 1.0],
881            centroid: [1.0, 2.0, 3.0],
882        };
883
884        apply_selection_transform(&selection);
885
886        let transform = get_point_cloud_transform(&name).unwrap();
887        let translation = transform.w_axis.truncate();
888        assert!((translation - Vec3::new(1.0, 2.0, 3.0)).length() < 0.001);
889
890        deselect_structure();
891    }
892}