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/// Removes all structures, groups, slice planes, floating quantities,
222/// and clears callbacks.
223///
224/// Unlike shutting down, this preserves render engine state and options.
225/// Useful for resetting the scene while keeping the app running.
226/// Matches C++ Polyscope's `removeEverything()` (commit f34f403).
227pub fn remove_everything() {
228    remove_all_structures();
229    remove_all_groups();
230    remove_all_slice_planes();
231    remove_all_floating_quantities();
232    clear_file_drop_callback();
233}
234
235/// Sets a callback that is invoked when files are dropped onto the polyscope window.
236///
237/// The callback receives a slice of file paths that were dropped.
238///
239/// # Example
240/// ```no_run
241/// polyscope_rs::set_file_drop_callback(|paths| {
242///     for path in paths {
243///         println!("Dropped: {}", path.display());
244///     }
245/// });
246/// ```
247pub fn set_file_drop_callback(callback: impl FnMut(&[std::path::PathBuf]) + Send + Sync + 'static) {
248    with_context_mut(|ctx| {
249        ctx.file_drop_callback = Some(Box::new(callback));
250    });
251}
252
253/// Clears the file drop callback.
254pub fn clear_file_drop_callback() {
255    with_context_mut(|ctx| {
256        ctx.file_drop_callback = None;
257    });
258}
259
260/// Loads a blendable (4-channel, RGB-tintable) matcap material from disk.
261///
262/// Takes a name and 4 image file paths for R, G, B, K matcap channels.
263/// The material becomes available in the UI material selector on the next frame.
264///
265/// Supports HDR, JPEG, PNG, EXR, and other image formats.
266///
267/// # Example
268/// ```no_run
269/// polyscope_rs::load_blendable_material("metal", [
270///     "assets/metal_r.hdr",
271///     "assets/metal_g.hdr",
272///     "assets/metal_b.hdr",
273///     "assets/metal_k.hdr",
274/// ]);
275/// ```
276pub fn load_blendable_material(name: &str, filenames: [&str; 4]) {
277    with_context_mut(|ctx| {
278        ctx.material_load_queue
279            .push(polyscope_core::state::MaterialLoadRequest::Blendable {
280                name: name.to_string(),
281                filenames: [
282                    filenames[0].to_string(),
283                    filenames[1].to_string(),
284                    filenames[2].to_string(),
285                    filenames[3].to_string(),
286                ],
287            });
288    });
289}
290
291/// Loads a blendable material using a base path and extension.
292///
293/// Automatically expands to 4 filenames by appending `_r`, `_g`, `_b`, `_k`
294/// before the extension. For example:
295/// `load_blendable_material_ext("metal", "assets/metal", ".hdr")`
296/// loads `assets/metal_r.hdr`, `assets/metal_g.hdr`, `assets/metal_b.hdr`, `assets/metal_k.hdr`.
297pub fn load_blendable_material_ext(name: &str, base: &str, ext: &str) {
298    load_blendable_material(
299        name,
300        [
301            &format!("{base}_r{ext}"),
302            &format!("{base}_g{ext}"),
303            &format!("{base}_b{ext}"),
304            &format!("{base}_k{ext}"),
305        ],
306    );
307}
308
309/// Loads a static (single-texture, non-RGB-tintable) matcap material from disk.
310///
311/// The same texture is used for all 4 matcap channels. Static materials
312/// cannot be tinted with per-surface RGB colors.
313///
314/// # Example
315/// ```no_run
316/// polyscope_rs::load_static_material("stone", "assets/stone.jpg");
317/// ```
318pub fn load_static_material(name: &str, filename: &str) {
319    with_context_mut(|ctx| {
320        ctx.material_load_queue
321            .push(polyscope_core::state::MaterialLoadRequest::Static {
322                name: name.to_string(),
323                path: filename.to_string(),
324            });
325    });
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::sync::atomic::{AtomicU32, Ordering};
332
333    // Counter for unique test names to avoid race conditions
334    static COUNTER: AtomicU32 = AtomicU32::new(0);
335
336    fn unique_name(prefix: &str) -> String {
337        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
338        format!("{prefix}_{n}")
339    }
340
341    fn setup() {
342        // Initialize context (only once)
343        // Use ok() to handle race conditions in parallel tests
344        let _ = init();
345    }
346
347    #[test]
348    fn test_register_curve_network() {
349        setup();
350        let name = unique_name("test_cn");
351        let nodes = vec![
352            Vec3::new(0.0, 0.0, 0.0),
353            Vec3::new(1.0, 0.0, 0.0),
354            Vec3::new(1.0, 1.0, 0.0),
355        ];
356        let edges = vec![[0, 1], [1, 2]];
357
358        let handle = register_curve_network(&name, nodes, edges);
359        assert_eq!(handle.name(), name);
360
361        // Verify it's retrievable
362        let found = get_curve_network(&name);
363        assert!(found.is_some());
364
365        // Verify non-existent returns None
366        let not_found = get_curve_network("nonexistent_xyz_123");
367        assert!(not_found.is_none());
368    }
369
370    #[test]
371    fn test_register_curve_network_line() {
372        setup();
373        let name = unique_name("line");
374        let nodes = vec![
375            Vec3::new(0.0, 0.0, 0.0),
376            Vec3::new(1.0, 0.0, 0.0),
377            Vec3::new(2.0, 0.0, 0.0),
378            Vec3::new(3.0, 0.0, 0.0),
379        ];
380
381        register_curve_network_line(&name, nodes);
382
383        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
384        assert_eq!(num_edges, Some(3)); // 0-1, 1-2, 2-3
385    }
386
387    #[test]
388    fn test_register_curve_network_loop() {
389        setup();
390        let name = unique_name("loop");
391        let nodes = vec![
392            Vec3::new(0.0, 0.0, 0.0),
393            Vec3::new(1.0, 0.0, 0.0),
394            Vec3::new(1.0, 1.0, 0.0),
395        ];
396
397        register_curve_network_loop(&name, nodes);
398
399        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
400        assert_eq!(num_edges, Some(3)); // 0-1, 1-2, 2-0
401    }
402
403    #[test]
404    fn test_register_curve_network_segments() {
405        setup();
406        let name = unique_name("segs");
407        let nodes = vec![
408            Vec3::new(0.0, 0.0, 0.0),
409            Vec3::new(1.0, 0.0, 0.0),
410            Vec3::new(2.0, 0.0, 0.0),
411            Vec3::new(3.0, 0.0, 0.0),
412        ];
413
414        register_curve_network_segments(&name, nodes);
415
416        let num_edges = with_curve_network_ref(&name, |cn| cn.num_edges());
417        assert_eq!(num_edges, Some(2)); // 0-1, 2-3
418    }
419
420    #[test]
421    fn test_curve_network_handle_methods() {
422        setup();
423        let name = unique_name("handle_test");
424        let nodes = vec![Vec3::ZERO, Vec3::X];
425        let edges = vec![[0, 1]];
426
427        let handle = register_curve_network(&name, nodes, edges);
428
429        // Test chained setters
430        handle
431            .set_color(Vec3::new(1.0, 0.0, 0.0))
432            .set_radius(0.1, false)
433            .set_material("clay");
434
435        // Verify values were set
436        with_curve_network_ref(&name, |cn| {
437            assert_eq!(cn.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
438            assert_eq!(cn.radius(), 0.1);
439            assert!(!cn.radius_is_relative());
440            assert_eq!(cn.material(), "clay");
441        });
442    }
443
444    #[test]
445    fn test_with_curve_network() {
446        setup();
447        let name = unique_name("with_test");
448        let nodes = vec![Vec3::ZERO, Vec3::X, Vec3::Y];
449        let edges = vec![[0, 1], [1, 2]];
450
451        register_curve_network(&name, nodes, edges);
452
453        // Test mutable access
454        let result = with_curve_network(&name, |cn| {
455            cn.set_color(Vec3::new(0.5, 0.5, 0.5));
456            cn.num_nodes()
457        });
458        assert_eq!(result, Some(3));
459
460        // Verify mutation persisted
461        let color = with_curve_network_ref(&name, |cn| cn.color());
462        assert_eq!(color, Some(Vec4::new(0.5, 0.5, 0.5, 1.0)));
463    }
464
465    #[test]
466    fn test_create_group() {
467        setup();
468        let name = unique_name("test_group");
469        let handle = create_group(&name);
470        assert_eq!(handle.name(), name);
471        assert!(handle.is_enabled());
472    }
473
474    #[test]
475    fn test_get_group() {
476        setup();
477        let name = unique_name("get_group");
478        create_group(&name);
479
480        let found = get_group(&name);
481        assert!(found.is_some());
482        assert_eq!(found.unwrap().name(), name);
483
484        let not_found = get_group("nonexistent_group_xyz");
485        assert!(not_found.is_none());
486    }
487
488    #[test]
489    fn test_group_enable_disable() {
490        setup();
491        let name = unique_name("enable_group");
492        let handle = create_group(&name);
493
494        assert!(handle.is_enabled());
495        handle.set_enabled(false);
496        assert!(!handle.is_enabled());
497        handle.set_enabled(true);
498        assert!(handle.is_enabled());
499    }
500
501    #[test]
502    fn test_group_add_structures() {
503        setup();
504        let group_name = unique_name("struct_group");
505        let pc_name = unique_name("pc_in_group");
506
507        // Create point cloud
508        register_point_cloud(&pc_name, vec![Vec3::ZERO, Vec3::X]);
509
510        // Create group and add point cloud
511        let handle = create_group(&group_name);
512        handle.add_point_cloud(&pc_name);
513
514        assert_eq!(handle.num_structures(), 1);
515    }
516
517    #[test]
518    fn test_group_hierarchy() {
519        setup();
520        let parent_name = unique_name("parent_group");
521        let child_name = unique_name("child_group");
522
523        let parent = create_group(&parent_name);
524        let _child = create_group(&child_name);
525
526        parent.add_child_group(&child_name);
527
528        assert_eq!(parent.num_child_groups(), 1);
529    }
530
531    #[test]
532    fn test_remove_group() {
533        setup();
534        let name = unique_name("remove_group");
535        create_group(&name);
536
537        assert!(get_group(&name).is_some());
538        remove_group(&name);
539        assert!(get_group(&name).is_none());
540    }
541
542    #[test]
543    fn test_add_slice_plane() {
544        setup();
545        let name = unique_name("slice_plane");
546        let handle = add_slice_plane(&name);
547        assert_eq!(handle.name(), name);
548        assert!(handle.is_enabled());
549    }
550
551    #[test]
552    fn test_slice_plane_pose() {
553        setup();
554        let name = unique_name("slice_pose");
555        let handle = add_slice_plane_with_pose(&name, Vec3::new(1.0, 2.0, 3.0), Vec3::X);
556
557        assert_eq!(handle.origin(), Vec3::new(1.0, 2.0, 3.0));
558        assert_eq!(handle.normal(), Vec3::X);
559    }
560
561    #[test]
562    fn test_slice_plane_setters() {
563        setup();
564        let name = unique_name("slice_setters");
565        let handle = add_slice_plane(&name);
566
567        handle
568            .set_origin(Vec3::new(1.0, 0.0, 0.0))
569            .set_normal(Vec3::Z)
570            .set_color(Vec3::new(1.0, 0.0, 0.0))
571            .set_transparency(0.5);
572
573        assert_eq!(handle.origin(), Vec3::new(1.0, 0.0, 0.0));
574        assert_eq!(handle.normal(), Vec3::Z);
575        assert_eq!(handle.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
576        assert!((handle.transparency() - 0.5).abs() < 0.001);
577    }
578
579    #[test]
580    fn test_slice_plane_enable_disable() {
581        setup();
582        let name = unique_name("slice_enable");
583        let handle = add_slice_plane(&name);
584
585        assert!(handle.is_enabled());
586        handle.set_enabled(false);
587        assert!(!handle.is_enabled());
588        handle.set_enabled(true);
589        assert!(handle.is_enabled());
590    }
591
592    #[test]
593    fn test_remove_slice_plane() {
594        setup();
595        let name = unique_name("slice_remove");
596        add_slice_plane(&name);
597
598        assert!(get_slice_plane(&name).is_some());
599        remove_slice_plane(&name);
600        assert!(get_slice_plane(&name).is_none());
601    }
602
603    #[test]
604    fn test_select_structure() {
605        setup();
606        let name = unique_name("select_pc");
607        register_point_cloud(&name, vec![Vec3::ZERO]);
608
609        assert!(!has_selection());
610
611        select_structure("PointCloud", &name);
612        assert!(has_selection());
613
614        let selected = get_selected_structure();
615        assert!(selected.is_some());
616        let (type_name, struct_name) = selected.unwrap();
617        assert_eq!(type_name, "PointCloud");
618        assert_eq!(struct_name, name);
619
620        deselect_structure();
621        assert!(!has_selection());
622    }
623
624    #[test]
625    fn test_slice_plane_gizmo_selection() {
626        setup();
627        let name = unique_name("slice_gizmo");
628        add_slice_plane(&name);
629
630        // Initially no slice plane selected
631        let info = get_slice_plane_selection_info();
632        assert!(!info.has_selection);
633
634        // Select slice plane
635        select_slice_plane_for_gizmo(&name);
636        let info = get_slice_plane_selection_info();
637        assert!(info.has_selection);
638        assert_eq!(info.name, name);
639
640        // Deselect slice plane
641        deselect_slice_plane_gizmo();
642        let info = get_slice_plane_selection_info();
643        assert!(!info.has_selection);
644    }
645
646    #[test]
647    fn test_slice_plane_structure_mutual_exclusion() {
648        setup();
649        let pc_name = unique_name("mutual_pc");
650        let plane_name = unique_name("mutual_plane");
651
652        register_point_cloud(&pc_name, vec![Vec3::ZERO]);
653        add_slice_plane(&plane_name);
654
655        // Select structure
656        select_structure("PointCloud", &pc_name);
657        assert!(has_selection());
658
659        // Select slice plane - should deselect structure
660        select_slice_plane_for_gizmo(&plane_name);
661        assert!(!has_selection()); // Structure should be deselected
662        let info = get_slice_plane_selection_info();
663        assert!(info.has_selection);
664
665        // Select structure again - should deselect slice plane
666        select_structure("PointCloud", &pc_name);
667        assert!(has_selection());
668        let info = get_slice_plane_selection_info();
669        assert!(!info.has_selection); // Slice plane should be deselected
670    }
671
672    #[test]
673    fn test_structure_transform() {
674        setup();
675        let name = unique_name("transform_pc");
676        register_point_cloud(&name, vec![Vec3::ZERO, Vec3::X]);
677
678        // Default transform is identity
679        let transform = get_point_cloud_transform(&name);
680        assert!(transform.is_some());
681
682        // Set a translation transform
683        let new_transform = Mat4::from_translation(Vec3::new(1.0, 2.0, 3.0));
684        set_point_cloud_transform(&name, new_transform);
685
686        let transform = get_point_cloud_transform(&name).unwrap();
687        let translation = transform.w_axis.truncate();
688        assert!((translation - Vec3::new(1.0, 2.0, 3.0)).length() < 0.001);
689    }
690
691    #[test]
692    fn test_get_slice_plane_settings() {
693        setup();
694        let name = unique_name("ui_slice_plane");
695
696        // Add a slice plane
697        add_slice_plane_with_pose(&name, Vec3::new(1.0, 2.0, 3.0), Vec3::X);
698
699        // Get settings
700        let settings = get_slice_plane_settings();
701        let found = settings.iter().find(|s| s.name == name);
702        assert!(found.is_some());
703
704        let s = found.unwrap();
705        assert_eq!(s.origin, [1.0, 2.0, 3.0]);
706        assert_eq!(s.normal, [1.0, 0.0, 0.0]);
707        assert!(s.enabled);
708    }
709
710    #[test]
711    fn test_apply_slice_plane_settings() {
712        setup();
713        let name = unique_name("apply_slice_plane");
714
715        // Add a slice plane
716        add_slice_plane(&name);
717
718        // Create modified settings
719        let settings = polyscope_ui::SlicePlaneSettings {
720            name: name.clone(),
721            enabled: false,
722            origin: [5.0, 6.0, 7.0],
723            normal: [0.0, 0.0, 1.0],
724            draw_plane: false,
725            draw_widget: true,
726            color: [1.0, 0.0, 0.0],
727            transparency: 0.8,
728            plane_size: 0.2,
729            is_selected: false,
730        };
731
732        // Apply settings
733        apply_slice_plane_settings(&settings);
734
735        // Verify
736        let handle = get_slice_plane(&name).unwrap();
737        assert!(!handle.is_enabled());
738        assert_eq!(handle.origin(), Vec3::new(5.0, 6.0, 7.0));
739        assert_eq!(handle.normal(), Vec3::Z);
740        assert!(!handle.draw_plane());
741        assert!(handle.draw_widget());
742        assert_eq!(handle.color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
743        assert!((handle.transparency() - 0.8).abs() < 0.001);
744    }
745
746    #[test]
747    fn test_handle_slice_plane_action_add() {
748        setup();
749        let name = unique_name("action_add_plane");
750        let mut settings = Vec::new();
751
752        handle_slice_plane_action(
753            polyscope_ui::SlicePlanesAction::Add(name.clone()),
754            &mut settings,
755        );
756
757        assert_eq!(settings.len(), 1);
758        assert_eq!(settings[0].name, name);
759        assert!(get_slice_plane(&name).is_some());
760    }
761
762    #[test]
763    fn test_handle_slice_plane_action_remove() {
764        setup();
765        let name = unique_name("action_remove_plane");
766
767        // Add plane
768        add_slice_plane(&name);
769        let mut settings = vec![polyscope_ui::SlicePlaneSettings::with_name(&name)];
770
771        // Remove via action
772        handle_slice_plane_action(polyscope_ui::SlicePlanesAction::Remove(0), &mut settings);
773
774        assert!(settings.is_empty());
775        assert!(get_slice_plane(&name).is_none());
776    }
777
778    #[test]
779    fn test_get_group_settings() {
780        setup();
781        let name = unique_name("ui_group");
782        let pc_name = unique_name("pc_in_ui_group");
783
784        // Create group and add a structure
785        let handle = create_group(&name);
786        register_point_cloud(&pc_name, vec![Vec3::ZERO]);
787        handle.add_point_cloud(&pc_name);
788
789        // Get settings
790        let settings = get_group_settings();
791        let found = settings.iter().find(|s| s.name == name);
792        assert!(found.is_some());
793
794        let s = found.unwrap();
795        assert!(s.enabled);
796        assert!(s.show_child_details);
797        assert_eq!(s.child_structures.len(), 1);
798        assert_eq!(s.child_structures[0], ("PointCloud".to_string(), pc_name));
799    }
800
801    #[test]
802    fn test_apply_group_settings() {
803        setup();
804        let name = unique_name("apply_group");
805
806        // Create group
807        create_group(&name);
808
809        // Create modified settings
810        let settings = polyscope_ui::GroupSettings {
811            name: name.clone(),
812            enabled: false,
813            show_child_details: false,
814            parent_group: None,
815            child_structures: Vec::new(),
816            child_groups: Vec::new(),
817        };
818
819        // Apply settings
820        apply_group_settings(&settings);
821
822        // Verify
823        let handle = get_group(&name).unwrap();
824        assert!(!handle.is_enabled());
825    }
826
827    #[test]
828    fn test_get_gizmo_settings() {
829        setup();
830
831        // Set known values
832        set_gizmo_space(GizmoSpace::Local);
833        set_gizmo_visible(false);
834        set_gizmo_snap_translate(0.5);
835        set_gizmo_snap_rotate(15.0);
836        set_gizmo_snap_scale(0.1);
837
838        let settings = get_gizmo_settings();
839        assert!(settings.local_space); // Local
840        assert!(!settings.visible);
841        assert!((settings.snap_translate - 0.5).abs() < 0.001);
842        assert!((settings.snap_rotate - 15.0).abs() < 0.001);
843        assert!((settings.snap_scale - 0.1).abs() < 0.001);
844    }
845
846    #[test]
847    fn test_apply_gizmo_settings() {
848        setup();
849
850        let settings = polyscope_ui::GizmoSettings {
851            local_space: false, // World
852            visible: true,
853            snap_translate: 1.0,
854            snap_rotate: 45.0,
855            snap_scale: 0.25,
856        };
857
858        apply_gizmo_settings(&settings);
859
860        assert_eq!(get_gizmo_space(), GizmoSpace::World);
861        assert!(is_gizmo_visible());
862    }
863
864    #[test]
865    fn test_get_selection_info_with_selection() {
866        setup();
867        let name = unique_name("gizmo_select_pc");
868
869        register_point_cloud(&name, vec![Vec3::ZERO]);
870        select_structure("PointCloud", &name);
871
872        let info = get_selection_info();
873        assert!(info.has_selection);
874        assert_eq!(info.type_name, "PointCloud");
875        assert_eq!(info.name, name);
876
877        deselect_structure();
878    }
879
880    #[test]
881    fn test_apply_selection_transform() {
882        setup();
883        let name = unique_name("gizmo_transform_pc");
884
885        register_point_cloud(&name, vec![Vec3::ZERO]);
886        select_structure("PointCloud", &name);
887
888        let selection = polyscope_ui::SelectionInfo {
889            has_selection: true,
890            type_name: "PointCloud".to_string(),
891            name: name.clone(),
892            translation: [1.0, 2.0, 3.0],
893            rotation_degrees: [0.0, 0.0, 0.0],
894            scale: [1.0, 1.0, 1.0],
895            centroid: [1.0, 2.0, 3.0],
896        };
897
898        apply_selection_transform(&selection);
899
900        let transform = get_point_cloud_transform(&name).unwrap();
901        let translation = transform.w_axis.truncate();
902        assert!((translation - Vec3::new(1.0, 2.0, 3.0)).length() < 0.001);
903
904        deselect_structure();
905    }
906
907    #[test]
908    fn test_remove_all_groups() {
909        setup();
910        let g1 = unique_name("rag_group1");
911        let g2 = unique_name("rag_group2");
912        create_group(&g1);
913        create_group(&g2);
914
915        assert!(get_group(&g1).is_some());
916        assert!(get_group(&g2).is_some());
917
918        remove_all_groups();
919
920        assert!(get_group(&g1).is_none());
921        assert!(get_group(&g2).is_none());
922    }
923
924    #[test]
925    fn test_remove_everything() {
926        setup();
927        let pc_name = unique_name("re_pc");
928        let group_name = unique_name("re_group");
929        let sp_name = unique_name("re_slice");
930
931        register_point_cloud(&pc_name, vec![Vec3::ZERO]);
932        create_group(&group_name);
933        add_slice_plane(&sp_name);
934
935        remove_everything();
936
937        assert!(get_point_cloud(&pc_name).is_none());
938        assert!(get_group(&group_name).is_none());
939        assert!(get_all_slice_planes().is_empty());
940    }
941
942    #[test]
943    fn test_degenerate_bounding_box() {
944        setup();
945        // Clear all structures so only our degenerate point cloud contributes
946        remove_all_structures();
947        let name = unique_name("degen_bbox");
948        // Register a point cloud where all points are at the same location
949        register_point_cloud(&name, vec![Vec3::ONE, Vec3::ONE, Vec3::ONE]);
950
951        let (bb_min, bb_max) = with_context(|ctx| ctx.bounding_box);
952        // Bounding box should be perturbed so min != max
953        assert!(
954            bb_max.x > bb_min.x,
955            "degenerate bbox not perturbed: min={bb_min}, max={bb_max}"
956        );
957        assert!(bb_max.y > bb_min.y);
958        assert!(bb_max.z > bb_min.z);
959    }
960}