Skip to main content

polyscope_ui/
panels.rs

1//! UI panel builders.
2
3use egui::{CollapsingHeader, Context, DragValue, SidePanel, Slider, Ui};
4
5/// Camera settings exposed in UI.
6#[derive(Debug, Clone)]
7pub struct CameraSettings {
8    /// Navigation style (0=Turntable, 1=Free, 2=Planar, 3=FirstPerson, 4=None)
9    pub navigation_style: u32,
10    /// Projection mode (0=Perspective, 1=Orthographic)
11    pub projection_mode: u32,
12    /// Up direction (0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z)
13    /// Note: Front direction is automatically derived using right-hand coordinate conventions.
14    pub up_direction: u32,
15    /// Field of view in degrees
16    pub fov_degrees: f32,
17    /// Near clip plane
18    pub near: f32,
19    /// Far clip plane
20    pub far: f32,
21    /// Movement speed
22    pub move_speed: f32,
23    /// Orthographic scale
24    pub ortho_scale: f32,
25}
26
27impl Default for CameraSettings {
28    fn default() -> Self {
29        Self {
30            navigation_style: 0, // Turntable
31            projection_mode: 0,  // Perspective
32            up_direction: 2,     // +Y (front direction auto-derived as -Z)
33            fov_degrees: 45.0,
34            near: 0.01,
35            far: 1000.0,
36            move_speed: 1.0,
37            ortho_scale: 1.0,
38        }
39    }
40}
41
42/// Scene extents information for UI display.
43#[derive(Debug, Clone, Default)]
44pub struct SceneExtents {
45    /// Whether to auto-compute extents.
46    pub auto_compute: bool,
47    /// Length scale of the scene.
48    pub length_scale: f32,
49    /// Bounding box minimum.
50    pub bbox_min: [f32; 3],
51    /// Bounding box maximum.
52    pub bbox_max: [f32; 3],
53}
54
55/// Appearance settings for UI.
56#[derive(Debug, Clone)]
57pub struct AppearanceSettings {
58    /// Transparency mode (0=None, 1=Simple, 2=Pretty/DepthPeeling)
59    pub transparency_mode: u32,
60    /// SSAA factor (1, 2, or 4)
61    pub ssaa_factor: u32,
62    /// Max FPS (0 = unlimited)
63    pub max_fps: u32,
64    /// SSAO enabled
65    pub ssao_enabled: bool,
66    /// SSAO radius (world units)
67    pub ssao_radius: f32,
68    /// SSAO intensity
69    pub ssao_intensity: f32,
70    /// SSAO bias
71    pub ssao_bias: f32,
72    /// SSAO sample count
73    pub ssao_sample_count: u32,
74}
75
76impl Default for AppearanceSettings {
77    fn default() -> Self {
78        Self {
79            transparency_mode: 1, // Simple (default)
80            ssaa_factor: 1,
81            max_fps: 60,
82            ssao_enabled: false,
83            ssao_radius: 0.5,
84            ssao_intensity: 1.5,
85            ssao_bias: 0.025,
86            ssao_sample_count: 32,
87        }
88    }
89}
90
91/// Tone mapping settings for UI.
92#[derive(Debug, Clone)]
93pub struct ToneMappingSettings {
94    /// Exposure value (0.1 - 4.0).
95    pub exposure: f32,
96    /// White level (0.5 - 4.0).
97    pub white_level: f32,
98    /// Gamma value (1.0 - 3.0).
99    pub gamma: f32,
100}
101
102impl Default for ToneMappingSettings {
103    fn default() -> Self {
104        Self {
105            exposure: 1.1,
106            white_level: 1.0,
107            gamma: 2.2,
108        }
109    }
110}
111
112/// Settings for a single slice plane in the UI.
113#[derive(Debug, Clone)]
114pub struct SlicePlaneSettings {
115    /// Name of the slice plane.
116    pub name: String,
117    /// Whether the slice plane is enabled.
118    pub enabled: bool,
119    /// Origin point (x, y, z).
120    pub origin: [f32; 3],
121    /// Normal direction (x, y, z).
122    pub normal: [f32; 3],
123    /// Whether to draw the plane visualization.
124    pub draw_plane: bool,
125    /// Whether to draw the widget.
126    pub draw_widget: bool,
127    /// Color of the plane (r, g, b).
128    pub color: [f32; 3],
129    /// Transparency (0.0 = transparent, 1.0 = opaque).
130    pub transparency: f32,
131    /// Size of the plane visualization (half-extent in each direction).
132    pub plane_size: f32,
133    /// Whether this plane is currently selected for gizmo manipulation.
134    pub is_selected: bool,
135}
136
137impl Default for SlicePlaneSettings {
138    fn default() -> Self {
139        Self {
140            name: String::new(),
141            enabled: true,
142            origin: [0.0, 0.0, 0.0],
143            normal: [0.0, 1.0, 0.0],
144            draw_plane: true,
145            draw_widget: true,
146            color: [0.5, 0.5, 0.5],
147            transparency: 0.3,
148            plane_size: 0.05,
149            is_selected: false,
150        }
151    }
152}
153
154impl SlicePlaneSettings {
155    /// Creates settings with a name.
156    pub fn with_name(name: impl Into<String>) -> Self {
157        Self {
158            name: name.into(),
159            ..Default::default()
160        }
161    }
162}
163
164/// Selection info for slice plane gizmo manipulation.
165#[derive(Debug, Clone, Default)]
166pub struct SlicePlaneSelectionInfo {
167    /// Whether a slice plane is selected.
168    pub has_selection: bool,
169    /// Name of the selected slice plane.
170    pub name: String,
171    /// Current origin position.
172    pub origin: [f32; 3],
173    /// Current rotation as Euler angles (degrees).
174    pub rotation_degrees: [f32; 3],
175}
176
177/// Actions specific to slice plane gizmo manipulation.
178#[derive(Debug, Clone, PartialEq, Default)]
179pub enum SlicePlaneGizmoAction {
180    /// No action.
181    #[default]
182    None,
183    /// Slice plane selection changed.
184    SelectionChanged,
185    /// Transform was updated via gizmo.
186    TransformChanged,
187    /// Deselect the slice plane.
188    Deselect,
189}
190
191/// Settings for a single group in the UI.
192#[derive(Debug, Clone)]
193pub struct GroupSettings {
194    /// Name of the group.
195    pub name: String,
196    /// Whether the group is enabled (visible).
197    pub enabled: bool,
198    /// Whether to show child details in UI.
199    pub show_child_details: bool,
200    /// Parent group name (if any).
201    pub parent_group: Option<String>,
202    /// Child structure identifiers (`type_name`, name).
203    pub child_structures: Vec<(String, String)>,
204    /// Child group names.
205    pub child_groups: Vec<String>,
206}
207
208impl Default for GroupSettings {
209    fn default() -> Self {
210        Self {
211            name: String::new(),
212            enabled: true,
213            show_child_details: true,
214            parent_group: None,
215            child_structures: Vec::new(),
216            child_groups: Vec::new(),
217        }
218    }
219}
220
221impl GroupSettings {
222    /// Creates settings with a name.
223    pub fn with_name(name: impl Into<String>) -> Self {
224        Self {
225            name: name.into(),
226            ..Default::default()
227        }
228    }
229}
230
231/// Actions that can be triggered from the groups UI.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum GroupsAction {
234    /// No action.
235    None,
236    /// Enabled state changed for groups at these indices (includes cascaded children).
237    SyncEnabled(Vec<usize>),
238}
239
240/// Gizmo settings for UI.
241#[derive(Debug, Clone)]
242pub struct GizmoSettings {
243    /// Use local coordinate space (true) or world space (false).
244    pub local_space: bool,
245    /// Whether gizmo is visible.
246    pub visible: bool,
247    /// Translation snap value (0 = disabled).
248    pub snap_translate: f32,
249    /// Rotation snap value in degrees (0 = disabled).
250    pub snap_rotate: f32,
251    /// Scale snap value (0 = disabled).
252    pub snap_scale: f32,
253}
254
255impl Default for GizmoSettings {
256    fn default() -> Self {
257        Self {
258            local_space: false, // World space by default
259            visible: true,
260            snap_translate: 0.0,
261            snap_rotate: 0.0,
262            snap_scale: 0.0,
263        }
264    }
265}
266
267/// Current selection info for UI.
268#[derive(Debug, Clone, Default)]
269pub struct SelectionInfo {
270    /// Whether something is selected.
271    pub has_selection: bool,
272    /// Selected structure type name.
273    pub type_name: String,
274    /// Selected structure name.
275    pub name: String,
276    /// Transform translation.
277    pub translation: [f32; 3],
278    /// Transform rotation as Euler angles in degrees.
279    pub rotation_degrees: [f32; 3],
280    /// Transform scale.
281    pub scale: [f32; 3],
282    /// Bounding box centroid (world space) - used for gizmo positioning.
283    pub centroid: [f32; 3],
284}
285
286/// Actions that can be triggered from the gizmo UI.
287#[derive(Debug, Clone, PartialEq)]
288pub enum GizmoAction {
289    /// No action.
290    None,
291    /// Gizmo settings changed.
292    SettingsChanged,
293    /// Transform was edited.
294    TransformChanged,
295    /// Deselect was clicked.
296    Deselect,
297    /// Reset transform was clicked.
298    ResetTransform,
299}
300
301/// Actions that can be triggered from the view/controls UI.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum ViewAction {
304    /// No action.
305    None,
306    /// Reset view requested.
307    ResetView,
308    /// Screenshot requested.
309    Screenshot,
310}
311
312/// Actions from the material loading UI.
313#[derive(Debug, Clone, PartialEq)]
314pub enum MaterialAction {
315    /// No action.
316    None,
317    /// Load a static material from a single file.
318    LoadStatic { name: String, path: String },
319    /// Load a blendable material from base path + extension (auto-expands _r/_g/_b/_k).
320    LoadBlendable {
321        name: String,
322        base_path: String,
323        extension: String,
324    },
325}
326
327/// UI state for the material loading panel.
328#[derive(Debug, Clone, Default)]
329pub struct MaterialLoadState {
330    /// Material name input.
331    pub name: String,
332    /// File path input.
333    pub path: String,
334    /// Status message (success or error).
335    pub status: String,
336}
337
338/// Builds the gizmo/transform section.
339/// Returns an action if one was triggered.
340pub fn build_gizmo_section(
341    ui: &mut Ui,
342    settings: &mut GizmoSettings,
343    selection: &mut SelectionInfo,
344) -> GizmoAction {
345    let mut action = GizmoAction::None;
346
347    CollapsingHeader::new("Transform")
348        .default_open(false)
349        .show(ui, |ui| {
350            // Selection info
351            if selection.has_selection {
352                ui.horizontal(|ui| {
353                    ui.label(format!("[{}] {}", selection.type_name, selection.name));
354                    if ui.small_button("x").clicked() {
355                        action = GizmoAction::Deselect;
356                    }
357                });
358
359                ui.separator();
360
361                // Transform editing
362                // Translation
363                ui.horizontal(|ui| {
364                    ui.label("Pos:");
365                    let mut changed = false;
366                    changed |= ui
367                        .add(
368                            DragValue::new(&mut selection.translation[0])
369                                .speed(0.1)
370                                .prefix("X:"),
371                        )
372                        .changed();
373                    changed |= ui
374                        .add(
375                            DragValue::new(&mut selection.translation[1])
376                                .speed(0.1)
377                                .prefix("Y:"),
378                        )
379                        .changed();
380                    changed |= ui
381                        .add(
382                            DragValue::new(&mut selection.translation[2])
383                                .speed(0.1)
384                                .prefix("Z:"),
385                        )
386                        .changed();
387                    if changed {
388                        action = GizmoAction::TransformChanged;
389                    }
390                });
391
392                // Rotation (Euler angles)
393                ui.horizontal(|ui| {
394                    ui.label("Rot:");
395                    let mut changed = false;
396                    changed |= ui
397                        .add(
398                            DragValue::new(&mut selection.rotation_degrees[0])
399                                .speed(1.0)
400                                .prefix("X:")
401                                .suffix("°"),
402                        )
403                        .changed();
404                    changed |= ui
405                        .add(
406                            DragValue::new(&mut selection.rotation_degrees[1])
407                                .speed(1.0)
408                                .prefix("Y:")
409                                .suffix("°"),
410                        )
411                        .changed();
412                    changed |= ui
413                        .add(
414                            DragValue::new(&mut selection.rotation_degrees[2])
415                                .speed(1.0)
416                                .prefix("Z:")
417                                .suffix("°"),
418                        )
419                        .changed();
420                    if changed && action == GizmoAction::None {
421                        action = GizmoAction::TransformChanged;
422                    }
423                });
424
425                // Scale
426                ui.horizontal(|ui| {
427                    ui.label("Scale:");
428                    let mut changed = false;
429                    changed |= ui
430                        .add(
431                            DragValue::new(&mut selection.scale[0])
432                                .speed(0.01)
433                                .prefix("X:")
434                                .range(0.01..=100.0),
435                        )
436                        .changed();
437                    changed |= ui
438                        .add(
439                            DragValue::new(&mut selection.scale[1])
440                                .speed(0.01)
441                                .prefix("Y:")
442                                .range(0.01..=100.0),
443                        )
444                        .changed();
445                    changed |= ui
446                        .add(
447                            DragValue::new(&mut selection.scale[2])
448                                .speed(0.01)
449                                .prefix("Z:")
450                                .range(0.01..=100.0),
451                        )
452                        .changed();
453                    if changed && action == GizmoAction::None {
454                        action = GizmoAction::TransformChanged;
455                    }
456                });
457
458                ui.horizontal(|ui| {
459                    if ui.button("Reset").clicked() {
460                        action = GizmoAction::ResetTransform;
461                    }
462                    ui.separator();
463                    if ui.checkbox(&mut settings.visible, "Gizmo").changed() {
464                        action = GizmoAction::SettingsChanged;
465                    }
466                    if settings.visible {
467                        if ui
468                            .selectable_label(!settings.local_space, "W")
469                            .on_hover_text("World space")
470                            .clicked()
471                        {
472                            settings.local_space = false;
473                            action = GizmoAction::SettingsChanged;
474                        }
475                        if ui
476                            .selectable_label(settings.local_space, "L")
477                            .on_hover_text("Local space")
478                            .clicked()
479                        {
480                            settings.local_space = true;
481                            action = GizmoAction::SettingsChanged;
482                        }
483                    }
484                });
485            } else {
486                ui.label("No selection");
487            }
488        });
489
490    action
491}
492
493/// Counts the total number of structures in a group and all its descendant groups.
494fn count_structures_recursive(idx: usize, groups: &[GroupSettings]) -> usize {
495    let mut total = groups[idx].child_structures.len();
496    let name = &groups[idx].name;
497    for (i, g) in groups.iter().enumerate() {
498        if g.parent_group.as_deref() == Some(name) {
499            total += count_structures_recursive(i, groups);
500        }
501    }
502    total
503}
504
505/// Collects the index of a group and all its descendant groups (recursive).
506fn collect_descendant_indices(idx: usize, groups: &[GroupSettings], out: &mut Vec<usize>) {
507    let name = &groups[idx].name;
508    for (i, g) in groups.iter().enumerate() {
509        if g.parent_group.as_deref() == Some(name) {
510            out.push(i);
511            collect_descendant_indices(i, groups, out);
512        }
513    }
514}
515
516/// Renders a group checkbox and recursively renders its child groups indented.
517fn build_group_tree(
518    ui: &mut Ui,
519    idx: usize,
520    groups: &mut Vec<GroupSettings>,
521    toggled_idx: &mut Option<usize>,
522) {
523    let member_count = count_structures_recursive(idx, groups);
524    let label = format!("{} ({member_count})", groups[idx].name);
525
526    ui.horizontal(|ui| {
527        if ui.checkbox(&mut groups[idx].enabled, label).changed() && toggled_idx.is_none() {
528            *toggled_idx = Some(idx);
529        }
530    });
531
532    // Collect child group indices
533    let child_name = groups[idx].name.clone();
534    let child_indices: Vec<usize> = groups
535        .iter()
536        .enumerate()
537        .filter(|(_, g)| g.parent_group.as_deref() == Some(child_name.as_str()))
538        .map(|(i, _)| i)
539        .collect();
540
541    if !child_indices.is_empty() {
542        ui.indent(format!("group_children_{idx}"), |ui| {
543            for child_idx in child_indices {
544                build_group_tree(ui, child_idx, groups, toggled_idx);
545            }
546        });
547    }
548}
549
550/// Builds the groups section.
551/// Only shown when groups exist (groups are created programmatically via the API).
552/// Each group is shown with a checkbox to toggle visibility.
553/// Child groups are indented under their parent.
554/// Toggling a parent cascades the enabled state to all descendant groups.
555/// Returns an action if one was triggered.
556pub fn build_groups_section(ui: &mut Ui, groups: &mut Vec<GroupSettings>) -> GroupsAction {
557    if groups.is_empty() {
558        return GroupsAction::None;
559    }
560
561    let mut toggled_idx: Option<usize> = None;
562
563    CollapsingHeader::new("Groups")
564        .default_open(true)
565        .show(ui, |ui| {
566            // Find root groups (no parent)
567            let root_indices: Vec<usize> = groups
568                .iter()
569                .enumerate()
570                .filter(|(_, g)| g.parent_group.is_none())
571                .map(|(i, _)| i)
572                .collect();
573
574            for idx in root_indices {
575                build_group_tree(ui, idx, groups, &mut toggled_idx);
576            }
577        });
578
579    if let Some(idx) = toggled_idx {
580        // Cascade the new enabled state to all descendant groups
581        let new_state = groups[idx].enabled;
582        let mut affected = vec![idx];
583        collect_descendant_indices(idx, groups, &mut affected);
584        for &i in &affected[1..] {
585            groups[i].enabled = new_state;
586        }
587        GroupsAction::SyncEnabled(affected)
588    } else {
589        GroupsAction::None
590    }
591}
592
593/// Builds the main left panel.
594/// Returns the actual panel width in logical pixels (for dynamic pointer checks).
595pub fn build_left_panel(ctx: &Context, build_contents: impl FnOnce(&mut Ui)) -> f32 {
596    let resp = SidePanel::left("polyscope_main_panel")
597        .default_width(305.0)
598        .resizable(true)
599        .show(ctx, |ui| {
600            ui.heading("polyscope-rs");
601            ui.separator();
602            egui::ScrollArea::vertical()
603                .auto_shrink([false; 2])
604                .show(ui, |ui| {
605                    build_contents(ui);
606                });
607        });
608    resp.response.rect.width()
609}
610
611/// Builds the polyscope controls section.
612/// Returns an action if any button was clicked.
613pub fn build_controls_section(ui: &mut Ui, background_color: &mut [f32; 3]) -> ViewAction {
614    let mut action = ViewAction::None;
615
616    CollapsingHeader::new("View")
617        .default_open(false)
618        .show(ui, |ui| {
619            ui.horizontal(|ui| {
620                ui.label("Background:");
621                ui.color_edit_button_rgb(background_color);
622            });
623
624            ui.columns(2, |cols| {
625                let w = cols[0].available_width();
626                let h = cols[0].spacing().interact_size.y;
627                if cols[0]
628                    .add_sized([w, h], egui::Button::new("Reset View"))
629                    .clicked()
630                {
631                    action = ViewAction::ResetView;
632                }
633                if cols[1]
634                    .add_sized([w, h], egui::Button::new("Screenshot"))
635                    .clicked()
636                {
637                    action = ViewAction::Screenshot;
638                }
639            });
640
641            ui.label("Tip: Press F12 for quick screenshot");
642        });
643
644    action
645}
646
647/// Builds the camera settings section.
648/// Returns true if any setting changed.
649pub fn build_camera_settings_section(ui: &mut Ui, settings: &mut CameraSettings) -> bool {
650    let mut changed = false;
651
652    CollapsingHeader::new("Camera")
653        .default_open(false)
654        .show(ui, |ui| {
655            // Navigation style
656            egui::ComboBox::from_label("Navigation")
657                .selected_text(match settings.navigation_style {
658                    0 => "Turntable",
659                    1 => "Free",
660                    2 => "Planar",
661                    3 => "Arcball",
662                    4 => "First Person",
663                    _ => "None",
664                })
665                .show_ui(ui, |ui| {
666                    for (i, name) in [
667                        "Turntable",
668                        "Free",
669                        "Planar",
670                        "Arcball",
671                        "First Person",
672                        "None",
673                    ]
674                    .iter()
675                    .enumerate()
676                    {
677                        if ui
678                            .selectable_value(&mut settings.navigation_style, i as u32, *name)
679                            .changed()
680                        {
681                            changed = true;
682                        }
683                    }
684                });
685
686            // Projection mode
687            egui::ComboBox::from_label("Projection")
688                .selected_text(if settings.projection_mode == 0 {
689                    "Perspective"
690                } else {
691                    "Orthographic"
692                })
693                .show_ui(ui, |ui| {
694                    if ui
695                        .selectable_value(&mut settings.projection_mode, 0, "Perspective")
696                        .changed()
697                    {
698                        changed = true;
699                    }
700                    if ui
701                        .selectable_value(&mut settings.projection_mode, 1, "Orthographic")
702                        .changed()
703                    {
704                        changed = true;
705                    }
706                });
707
708            ui.separator();
709
710            // Up direction (front direction is auto-derived using right-hand rule)
711            let directions = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"];
712            // Front directions corresponding to each up direction (right-hand rule)
713            let front_for_up = ["+Y", "-Y", "-Z", "+Z", "+X", "-X"];
714
715            egui::ComboBox::from_label("Up")
716                .selected_text(directions[settings.up_direction as usize])
717                .show_ui(ui, |ui| {
718                    for (i, name) in directions.iter().enumerate() {
719                        if ui
720                            .selectable_value(&mut settings.up_direction, i as u32, *name)
721                            .changed()
722                        {
723                            changed = true;
724                        }
725                    }
726                });
727
728            // Show the auto-derived front direction (read-only)
729            ui.horizontal(|ui| {
730                ui.label("Front:");
731                ui.label(front_for_up[settings.up_direction as usize]);
732                ui.label("(auto)");
733            });
734
735            ui.separator();
736
737            // FOV (only for perspective)
738            if settings.projection_mode == 0 {
739                ui.horizontal(|ui| {
740                    ui.label("FOV:");
741                    if ui
742                        .add(Slider::new(&mut settings.fov_degrees, 10.0..=170.0).suffix("°"))
743                        .changed()
744                    {
745                        changed = true;
746                    }
747                });
748            } else {
749                // Ortho scale
750                ui.horizontal(|ui| {
751                    ui.label("Scale:");
752                    if ui
753                        .add(
754                            DragValue::new(&mut settings.ortho_scale)
755                                .speed(0.1)
756                                .range(0.1..=100.0),
757                        )
758                        .changed()
759                    {
760                        changed = true;
761                    }
762                });
763            }
764
765            // Clip planes
766            ui.horizontal(|ui| {
767                ui.label("Near:");
768                if ui
769                    .add(
770                        DragValue::new(&mut settings.near)
771                            .speed(0.001)
772                            .range(0.001..=10.0),
773                    )
774                    .changed()
775                {
776                    changed = true;
777                }
778            });
779
780            ui.horizontal(|ui| {
781                ui.label("Far:");
782                if ui
783                    .add(
784                        DragValue::new(&mut settings.far)
785                            .speed(1.0)
786                            .range(10.0..=10000.0),
787                    )
788                    .changed()
789                {
790                    changed = true;
791                }
792            });
793
794            // Move speed
795            ui.horizontal(|ui| {
796                ui.label("Move Speed:");
797                if ui
798                    .add(
799                        DragValue::new(&mut settings.move_speed)
800                            .speed(0.1)
801                            .range(0.1..=10.0),
802                    )
803                    .changed()
804                {
805                    changed = true;
806                }
807            });
808        });
809
810    changed
811}
812
813/// Builds the scene extents section.
814/// Returns true if any setting changed (auto-compute toggle or manual edits).
815pub fn build_scene_extents_section(ui: &mut Ui, extents: &mut SceneExtents) -> bool {
816    let mut changed = false;
817
818    CollapsingHeader::new("Scene Extents")
819        .default_open(false)
820        .show(ui, |ui| {
821            if ui
822                .checkbox(&mut extents.auto_compute, "Auto-compute")
823                .changed()
824            {
825                changed = true;
826            }
827
828            ui.separator();
829
830            if extents.auto_compute {
831                // Auto-compute ON: read-only display
832                ui.horizontal(|ui| {
833                    ui.label("Length scale:");
834                    ui.label(format!("{:.4}", extents.length_scale));
835                });
836
837                ui.label("Bounding box:");
838                ui.indent("bbox", |ui| {
839                    ui.horizontal(|ui| {
840                        ui.label("Min:");
841                        ui.label(format!(
842                            "({:.2}, {:.2}, {:.2})",
843                            extents.bbox_min[0], extents.bbox_min[1], extents.bbox_min[2]
844                        ));
845                    });
846                    ui.horizontal(|ui| {
847                        ui.label("Max:");
848                        ui.label(format!(
849                            "({:.2}, {:.2}, {:.2})",
850                            extents.bbox_max[0], extents.bbox_max[1], extents.bbox_max[2]
851                        ));
852                    });
853                });
854            } else {
855                // Auto-compute OFF: editable controls (matching C++ Polyscope)
856                ui.horizontal(|ui| {
857                    ui.label("Length scale:");
858                    if ui
859                        .add(
860                            DragValue::new(&mut extents.length_scale)
861                                .speed(0.01)
862                                .range(0.0001..=f32::MAX),
863                        )
864                        .changed()
865                    {
866                        changed = true;
867                    }
868                });
869
870                ui.label("Bounding box:");
871                ui.indent("bbox_edit", |ui| {
872                    ui.horizontal(|ui| {
873                        ui.label("Min:");
874                        for val in &mut extents.bbox_min {
875                            if ui.add(DragValue::new(val).speed(0.01)).changed() {
876                                changed = true;
877                            }
878                        }
879                    });
880                    ui.horizontal(|ui| {
881                        ui.label("Max:");
882                        for val in &mut extents.bbox_max {
883                            if ui.add(DragValue::new(val).speed(0.01)).changed() {
884                                changed = true;
885                            }
886                        }
887                    });
888                });
889            }
890
891            // Compute center and display
892            let center = [
893                f32::midpoint(extents.bbox_min[0], extents.bbox_max[0]),
894                f32::midpoint(extents.bbox_min[1], extents.bbox_max[1]),
895                f32::midpoint(extents.bbox_min[2], extents.bbox_max[2]),
896            ];
897            ui.horizontal(|ui| {
898                ui.label("Center:");
899                ui.label(format!(
900                    "({:.2}, {:.2}, {:.2})",
901                    center[0], center[1], center[2]
902                ));
903            });
904        });
905
906    changed
907}
908
909/// Builds the appearance settings section.
910/// Returns true if any setting changed.
911pub fn build_appearance_section(ui: &mut Ui, settings: &mut AppearanceSettings) -> bool {
912    let mut changed = false;
913
914    CollapsingHeader::new("Appearance")
915        .default_open(false)
916        .show(ui, |ui| {
917            // Transparency mode
918            egui::ComboBox::from_label("Transparency")
919                .selected_text(match settings.transparency_mode {
920                    0 => "None",
921                    1 => "Simple",
922                    _ => "Pretty",
923                })
924                .show_ui(ui, |ui| {
925                    if ui
926                        .selectable_value(&mut settings.transparency_mode, 0, "None")
927                        .changed()
928                    {
929                        changed = true;
930                    }
931                    if ui
932                        .selectable_value(&mut settings.transparency_mode, 1, "Simple")
933                        .changed()
934                    {
935                        changed = true;
936                    }
937                    if ui
938                        .selectable_value(&mut settings.transparency_mode, 2, "Pretty")
939                        .changed()
940                    {
941                        changed = true;
942                    }
943                });
944
945            ui.separator();
946
947            // SSAA factor
948            egui::ComboBox::from_label("Anti-aliasing")
949                .selected_text(format!("{}x SSAA", settings.ssaa_factor))
950                .show_ui(ui, |ui| {
951                    if ui
952                        .selectable_value(&mut settings.ssaa_factor, 1, "1x (Off)")
953                        .changed()
954                    {
955                        changed = true;
956                    }
957                    if ui
958                        .selectable_value(&mut settings.ssaa_factor, 2, "2x SSAA")
959                        .changed()
960                    {
961                        changed = true;
962                    }
963                    if ui
964                        .selectable_value(&mut settings.ssaa_factor, 4, "4x SSAA")
965                        .changed()
966                    {
967                        changed = true;
968                    }
969                });
970
971            ui.separator();
972
973            // Max FPS
974            ui.horizontal(|ui| {
975                ui.label("Max FPS:");
976                let mut fps = settings.max_fps as i32;
977                if ui.add(DragValue::new(&mut fps).range(0..=240)).changed() {
978                    settings.max_fps = fps.max(0) as u32;
979                    changed = true;
980                }
981                if settings.max_fps == 0 {
982                    ui.label("(unlimited)");
983                }
984            });
985
986            ui.separator();
987
988            // SSAO
989            if ui.checkbox(&mut settings.ssao_enabled, "SSAO").changed() {
990                changed = true;
991            }
992
993            if settings.ssao_enabled {
994                egui::Grid::new("ssao_grid").num_columns(2).show(ui, |ui| {
995                    ui.label("Radius:");
996                    if ui
997                        .add(Slider::new(&mut settings.ssao_radius, 0.01..=2.0))
998                        .changed()
999                    {
1000                        changed = true;
1001                    }
1002                    ui.end_row();
1003
1004                    ui.label("Intensity:");
1005                    if ui
1006                        .add(Slider::new(&mut settings.ssao_intensity, 0.1..=3.0))
1007                        .changed()
1008                    {
1009                        changed = true;
1010                    }
1011                    ui.end_row();
1012
1013                    ui.label("Bias:");
1014                    if ui
1015                        .add(Slider::new(&mut settings.ssao_bias, 0.001..=0.1))
1016                        .changed()
1017                    {
1018                        changed = true;
1019                    }
1020                    ui.end_row();
1021
1022                    ui.label("Samples:");
1023                    let mut samples = settings.ssao_sample_count as i32;
1024                    if ui.add(DragValue::new(&mut samples).range(4..=64)).changed() {
1025                        settings.ssao_sample_count = samples.max(4) as u32;
1026                        changed = true;
1027                    }
1028                    ui.end_row();
1029                });
1030            }
1031        });
1032
1033    changed
1034}
1035
1036/// Builds the material loading section in the left panel.
1037/// Returns a `MaterialAction` if the user requested loading.
1038pub fn build_material_section(ui: &mut Ui, state: &mut MaterialLoadState) -> MaterialAction {
1039    let mut action = MaterialAction::None;
1040
1041    CollapsingHeader::new("Materials")
1042        .default_open(false)
1043        .show(ui, |ui| {
1044            egui::Grid::new("material_load_grid")
1045                .num_columns(2)
1046                .show(ui, |ui| {
1047                    ui.label("Name:");
1048                    ui.text_edit_singleline(&mut state.name);
1049                    ui.end_row();
1050
1051                    ui.label("File path:");
1052                    ui.text_edit_singleline(&mut state.path);
1053                    ui.end_row();
1054                });
1055
1056            ui.horizontal(|ui| {
1057                if ui.button("Load Static").clicked()
1058                    && !state.name.is_empty()
1059                    && !state.path.is_empty()
1060                {
1061                    action = MaterialAction::LoadStatic {
1062                        name: state.name.clone(),
1063                        path: state.path.clone(),
1064                    };
1065                }
1066                if ui.button("Load Blendable").clicked()
1067                    && !state.name.is_empty()
1068                    && !state.path.is_empty()
1069                {
1070                    // Split path into base + extension for _r/_g/_b/_k expansion
1071                    let p = std::path::Path::new(&state.path);
1072                    let ext = p
1073                        .extension()
1074                        .map(|e| format!(".{}", e.to_string_lossy()))
1075                        .unwrap_or_default();
1076                    let base = state
1077                        .path
1078                        .strip_suffix(&ext)
1079                        .unwrap_or(&state.path)
1080                        .to_string();
1081                    action = MaterialAction::LoadBlendable {
1082                        name: state.name.clone(),
1083                        base_path: base,
1084                        extension: ext,
1085                    };
1086                }
1087            });
1088
1089            if !state.status.is_empty() {
1090                ui.label(&state.status);
1091            }
1092        });
1093
1094    action
1095}
1096
1097/// Builds the tone mapping settings section.
1098/// Returns true if any setting changed.
1099pub fn build_tone_mapping_section(ui: &mut Ui, settings: &mut ToneMappingSettings) -> bool {
1100    let mut changed = false;
1101
1102    CollapsingHeader::new("Tone Mapping")
1103        .default_open(false)
1104        .show(ui, |ui| {
1105            egui::Grid::new("tone_mapping_grid")
1106                .num_columns(2)
1107                .show(ui, |ui| {
1108                    ui.label("Exposure:");
1109                    if ui
1110                        .add(
1111                            Slider::new(&mut settings.exposure, 0.1..=4.0)
1112                                .logarithmic(true)
1113                                .clamping(egui::SliderClamping::Always),
1114                        )
1115                        .changed()
1116                    {
1117                        changed = true;
1118                    }
1119                    ui.end_row();
1120
1121                    ui.label("White Level:");
1122                    if ui
1123                        .add(
1124                            Slider::new(&mut settings.white_level, 0.5..=4.0)
1125                                .logarithmic(true)
1126                                .clamping(egui::SliderClamping::Always),
1127                        )
1128                        .changed()
1129                    {
1130                        changed = true;
1131                    }
1132                    ui.end_row();
1133
1134                    ui.label("Gamma:");
1135                    if ui
1136                        .add(
1137                            Slider::new(&mut settings.gamma, 1.0..=3.0)
1138                                .clamping(egui::SliderClamping::Always),
1139                        )
1140                        .changed()
1141                    {
1142                        changed = true;
1143                    }
1144                    ui.end_row();
1145                });
1146
1147            ui.separator();
1148            if ui.button("Reset to Defaults").clicked() {
1149                *settings = ToneMappingSettings::default();
1150                changed = true;
1151            }
1152        });
1153
1154    changed
1155}
1156
1157/// Actions that can be triggered from the slice planes UI.
1158#[derive(Debug, Clone, PartialEq, Eq)]
1159pub enum SlicePlanesAction {
1160    /// No action.
1161    None,
1162    /// Add a new slice plane with the given name.
1163    Add(String),
1164    /// Remove slice plane at the given index.
1165    Remove(usize),
1166    /// Settings for a plane were modified.
1167    Modified(usize),
1168}
1169
1170/// Builds UI for a single slice plane.
1171/// Returns true if any setting changed.
1172fn build_slice_plane_item(ui: &mut Ui, settings: &mut SlicePlaneSettings) -> bool {
1173    let mut changed = false;
1174
1175    // Enabled checkbox
1176    ui.horizontal(|ui| {
1177        if ui.checkbox(&mut settings.enabled, "Enabled").changed() {
1178            changed = true;
1179        }
1180    });
1181
1182    ui.separator();
1183
1184    // Origin
1185    ui.label("Origin:");
1186    ui.horizontal(|ui| {
1187        ui.label("X:");
1188        if ui
1189            .add(DragValue::new(&mut settings.origin[0]).speed(0.1))
1190            .changed()
1191        {
1192            changed = true;
1193        }
1194        ui.label("Y:");
1195        if ui
1196            .add(DragValue::new(&mut settings.origin[1]).speed(0.1))
1197            .changed()
1198        {
1199            changed = true;
1200        }
1201        ui.label("Z:");
1202        if ui
1203            .add(DragValue::new(&mut settings.origin[2]).speed(0.1))
1204            .changed()
1205        {
1206            changed = true;
1207        }
1208    });
1209
1210    // Normal direction with preset buttons
1211    ui.label("Normal:");
1212    ui.columns(6, |cols| {
1213        let w = cols[0].available_width();
1214        let h = cols[0].spacing().interact_size.y;
1215        if cols[0].add_sized([w, h], egui::Button::new("+X")).clicked() {
1216            settings.normal = [1.0, 0.0, 0.0];
1217            changed = true;
1218        }
1219        if cols[1].add_sized([w, h], egui::Button::new("-X")).clicked() {
1220            settings.normal = [-1.0, 0.0, 0.0];
1221            changed = true;
1222        }
1223        if cols[2].add_sized([w, h], egui::Button::new("+Y")).clicked() {
1224            settings.normal = [0.0, 1.0, 0.0];
1225            changed = true;
1226        }
1227        if cols[3].add_sized([w, h], egui::Button::new("-Y")).clicked() {
1228            settings.normal = [0.0, -1.0, 0.0];
1229            changed = true;
1230        }
1231        if cols[4].add_sized([w, h], egui::Button::new("+Z")).clicked() {
1232            settings.normal = [0.0, 0.0, 1.0];
1233            changed = true;
1234        }
1235        if cols[5].add_sized([w, h], egui::Button::new("-Z")).clicked() {
1236            settings.normal = [0.0, 0.0, -1.0];
1237            changed = true;
1238        }
1239    });
1240
1241    // Custom normal input
1242    ui.horizontal(|ui| {
1243        ui.label("X:");
1244        if ui
1245            .add(
1246                DragValue::new(&mut settings.normal[0])
1247                    .speed(0.01)
1248                    .range(-1.0..=1.0),
1249            )
1250            .changed()
1251        {
1252            changed = true;
1253        }
1254        ui.label("Y:");
1255        if ui
1256            .add(
1257                DragValue::new(&mut settings.normal[1])
1258                    .speed(0.01)
1259                    .range(-1.0..=1.0),
1260            )
1261            .changed()
1262        {
1263            changed = true;
1264        }
1265        ui.label("Z:");
1266        if ui
1267            .add(
1268                DragValue::new(&mut settings.normal[2])
1269                    .speed(0.01)
1270                    .range(-1.0..=1.0),
1271            )
1272            .changed()
1273        {
1274            changed = true;
1275        }
1276    });
1277
1278    ui.separator();
1279
1280    // Visualization options
1281    ui.horizontal(|ui| {
1282        if ui
1283            .checkbox(&mut settings.draw_plane, "Draw plane")
1284            .changed()
1285        {
1286            changed = true;
1287        }
1288        if ui
1289            .checkbox(&mut settings.draw_widget, "Draw widget")
1290            .changed()
1291        {
1292            changed = true;
1293        }
1294    });
1295
1296    // Gizmo control button (only show when draw_widget is enabled)
1297    if settings.draw_widget && settings.enabled {
1298        let gizmo_text = if settings.is_selected {
1299            "Deselect Gizmo"
1300        } else {
1301            "Edit with Gizmo"
1302        };
1303        if ui.button(gizmo_text).clicked() {
1304            settings.is_selected = !settings.is_selected;
1305            changed = true;
1306        }
1307    }
1308
1309    // Plane size & Color
1310    egui::Grid::new("slice_plane_props")
1311        .num_columns(2)
1312        .show(ui, |ui| {
1313            ui.label("Plane size:");
1314            if ui
1315                .add(Slider::new(&mut settings.plane_size, 0.01..=1.0).logarithmic(true))
1316                .changed()
1317            {
1318                changed = true;
1319            }
1320            ui.end_row();
1321
1322            ui.label("Color:");
1323            if ui.color_edit_button_rgb(&mut settings.color).changed() {
1324                changed = true;
1325            }
1326            ui.end_row();
1327        });
1328
1329    changed
1330}
1331
1332/// Builds the slice planes section.
1333/// Returns an action if one was triggered (add, remove, or modify).
1334pub fn build_slice_planes_section(
1335    ui: &mut Ui,
1336    planes: &mut Vec<SlicePlaneSettings>,
1337    new_plane_name: &mut String,
1338) -> SlicePlanesAction {
1339    let mut action = SlicePlanesAction::None;
1340
1341    CollapsingHeader::new("Slice Planes")
1342        .default_open(false)
1343        .show(ui, |ui| {
1344            // Add new plane controls
1345            ui.horizontal(|ui| {
1346                ui.label("New plane:");
1347                ui.add_sized([80.0, 18.0], egui::TextEdit::singleline(new_plane_name));
1348                if ui.button("Add").clicked() && !new_plane_name.is_empty() {
1349                    action = SlicePlanesAction::Add(new_plane_name.clone());
1350                }
1351            });
1352
1353            if planes.is_empty() {
1354                ui.label("No slice planes");
1355                return;
1356            }
1357
1358            ui.separator();
1359
1360            // List existing planes
1361            let mut remove_idx = None;
1362            for (idx, plane) in planes.iter_mut().enumerate() {
1363                let header_text =
1364                    format!("{} {}", if plane.enabled { "●" } else { "○" }, plane.name);
1365
1366                CollapsingHeader::new(header_text)
1367                    .id_salt(format!("slice_plane_{idx}"))
1368                    .default_open(false)
1369                    .show(ui, |ui| {
1370                        if build_slice_plane_item(ui, plane) && action == SlicePlanesAction::None {
1371                            action = SlicePlanesAction::Modified(idx);
1372                        }
1373
1374                        ui.separator();
1375                        if ui.button("Remove").clicked() {
1376                            remove_idx = Some(idx);
1377                        }
1378                    });
1379            }
1380
1381            if let Some(idx) = remove_idx {
1382                action = SlicePlanesAction::Remove(idx);
1383            }
1384        });
1385
1386    action
1387}
1388
1389/// Builds the ground plane settings section.
1390pub fn build_ground_plane_section(
1391    ui: &mut Ui,
1392    mode: &mut u32, // 0=None, 1=Tile, 2=ShadowOnly, 3=TileReflection
1393    height: &mut f32,
1394    height_is_relative: &mut bool,
1395    shadow_blur_iters: &mut u32,
1396    shadow_darkness: &mut f32,
1397    reflection_intensity: &mut f32,
1398) -> bool {
1399    let mut changed = false;
1400
1401    CollapsingHeader::new("Ground Plane")
1402        .default_open(false)
1403        .show(ui, |ui| {
1404            // Mode selector
1405            egui::ComboBox::from_label("Mode")
1406                .selected_text(match *mode {
1407                    0 => "None",
1408                    1 => "Tile",
1409                    2 => "Shadow Only",
1410                    3 => "Tile + Reflection",
1411                    _ => "Unknown",
1412                })
1413                .show_ui(ui, |ui| {
1414                    if ui.selectable_value(mode, 0, "None").changed() {
1415                        changed = true;
1416                    }
1417                    if ui.selectable_value(mode, 1, "Tile").changed() {
1418                        changed = true;
1419                    }
1420                    if ui.selectable_value(mode, 2, "Shadow Only").changed() {
1421                        changed = true;
1422                    }
1423                    if ui.selectable_value(mode, 3, "Tile + Reflection").changed() {
1424                        changed = true;
1425                    }
1426                });
1427
1428            if *mode > 0 {
1429                ui.separator();
1430
1431                // Height settings
1432                if ui.checkbox(height_is_relative, "Auto height").changed() {
1433                    changed = true;
1434                }
1435
1436                if !*height_is_relative {
1437                    ui.horizontal(|ui| {
1438                        ui.label("Height:");
1439                        if ui.add(egui::DragValue::new(height).speed(0.1)).changed() {
1440                            changed = true;
1441                        }
1442                    });
1443                }
1444
1445                // Shadow settings
1446                ui.separator();
1447                ui.label("Shadow Settings:");
1448
1449                egui::Grid::new("shadow_grid")
1450                    .num_columns(2)
1451                    .show(ui, |ui| {
1452                        ui.label("Blur iterations:");
1453                        if ui.add(Slider::new(shadow_blur_iters, 0..=5)).changed() {
1454                            changed = true;
1455                        }
1456                        ui.end_row();
1457
1458                        ui.label("Darkness:");
1459                        if ui.add(Slider::new(shadow_darkness, 0.0..=1.0)).changed() {
1460                            changed = true;
1461                        }
1462                        ui.end_row();
1463                    });
1464
1465                // Reflection settings (only for mode 3 - TileReflection)
1466                if *mode == 3 {
1467                    ui.separator();
1468                    ui.label("Reflection Settings:");
1469
1470                    egui::Grid::new("reflection_grid")
1471                        .num_columns(2)
1472                        .show(ui, |ui| {
1473                            ui.label("Intensity:");
1474                            if ui
1475                                .add(Slider::new(reflection_intensity, 0.0..=1.0))
1476                                .changed()
1477                            {
1478                                changed = true;
1479                            }
1480                            ui.end_row();
1481                        });
1482                }
1483            }
1484        });
1485
1486    changed
1487}
1488
1489/// Builds the structure tree section.
1490pub fn build_structure_tree<F>(
1491    ui: &mut Ui,
1492    structures: &[(String, String, bool)], // (type_name, name, enabled)
1493    mut on_toggle: F,
1494) where
1495    F: FnMut(&str, &str, bool), // (type_name, name, new_enabled)
1496{
1497    CollapsingHeader::new("Structures")
1498        .default_open(true)
1499        .show(ui, |ui| {
1500            if structures.is_empty() {
1501                ui.label("No structures registered");
1502                return;
1503            }
1504
1505            // Group by type using BTreeMap for stable, sorted ordering
1506            let mut by_type: std::collections::BTreeMap<&str, Vec<(&str, bool)>> =
1507                std::collections::BTreeMap::new();
1508            for (type_name, name, enabled) in structures {
1509                by_type
1510                    .entry(type_name.as_str())
1511                    .or_default()
1512                    .push((name.as_str(), *enabled));
1513            }
1514
1515            for (type_name, instances) in &by_type {
1516                // Sort instances by name for stable ordering
1517                let mut sorted_instances: Vec<_> = instances.iter().collect();
1518                sorted_instances.sort_by_key(|(name, _)| *name);
1519
1520                let header = format!("{} ({})", type_name, instances.len());
1521                CollapsingHeader::new(header)
1522                    .default_open(instances.len() <= 8)
1523                    .show(ui, |ui| {
1524                        for (name, enabled) in sorted_instances {
1525                            let mut enabled_mut = *enabled;
1526                            ui.horizontal(|ui| {
1527                                if ui.checkbox(&mut enabled_mut, "").changed() {
1528                                    on_toggle(type_name, name, enabled_mut);
1529                                }
1530                                ui.label(*name);
1531                            });
1532                        }
1533                    });
1534            }
1535        });
1536}
1537
1538/// Builds the structure tree section with per-structure UI support.
1539///
1540/// When a structure is expanded, the `build_ui` callback is invoked to build
1541/// the structure-specific UI (color picker, radius slider, etc.).
1542pub fn build_structure_tree_with_ui<F, U>(
1543    ui: &mut Ui,
1544    structures: &[(String, String, bool)], // (type_name, name, enabled)
1545    mut on_toggle: F,
1546    mut build_ui: U,
1547) where
1548    F: FnMut(&str, &str, bool),    // (type_name, name, new_enabled)
1549    U: FnMut(&mut Ui, &str, &str), // (ui, type_name, name)
1550{
1551    CollapsingHeader::new("Structures")
1552        .default_open(true)
1553        .show(ui, |ui| {
1554            if structures.is_empty() {
1555                ui.label("No structures registered");
1556                return;
1557            }
1558
1559            // Group by type using BTreeMap for stable, sorted ordering
1560            let mut by_type: std::collections::BTreeMap<&str, Vec<(&str, bool)>> =
1561                std::collections::BTreeMap::new();
1562            for (type_name, name, enabled) in structures {
1563                by_type
1564                    .entry(type_name.as_str())
1565                    .or_default()
1566                    .push((name.as_str(), *enabled));
1567            }
1568
1569            for (type_name, instances) in &by_type {
1570                // Sort instances by name for stable ordering
1571                let mut sorted_instances: Vec<_> = instances.iter().collect();
1572                sorted_instances.sort_by_key(|(name, _)| *name);
1573
1574                let header = format!("{} ({})", type_name, instances.len());
1575                CollapsingHeader::new(header)
1576                    .default_open(instances.len() <= 8)
1577                    .show(ui, |ui| {
1578                        for (name, enabled) in sorted_instances {
1579                            let mut enabled_mut = *enabled;
1580
1581                            // Use a collapsing header for each structure to show its UI
1582                            let structure_header = CollapsingHeader::new(*name)
1583                                .default_open(false)
1584                                .show(ui, |ui| {
1585                                    // Checkbox for enable/disable
1586                                    if ui.checkbox(&mut enabled_mut, "Enabled").changed() {
1587                                        on_toggle(type_name, name, enabled_mut);
1588                                    }
1589
1590                                    ui.separator();
1591
1592                                    // Build structure-specific UI
1593                                    build_ui(ui, type_name, name);
1594                                });
1595
1596                            // body_returned indicates if the collapsing header was expanded
1597                            // We don't need additional action when collapsed since the header shows the name
1598                            let _ = structure_header.body_returned;
1599                        }
1600                    });
1601            }
1602        });
1603}