From 4b5778ccf1425911dca3f5b217b5bf599849d854 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Thu, 7 Aug 2025 13:45:59 -0700 Subject: [PATCH 1/5] refactor: extract reusable UI components into dedicated widget modules - Create 12 new widget modules (pan3d_view, data_origin, data_information, etc.) - Move common UI patterns from explorers to centralized widgets - Introduce StandardExplorerLayout for consistent UI structure - Improve maintainability with single source of truth for UI components --- src/pan3d/explorers/analytics.py | 127 ++----- src/pan3d/explorers/contour.py | 112 ++---- src/pan3d/explorers/globe.py | 101 +----- src/pan3d/explorers/slicer.py | 243 ++++++------- src/pan3d/ui/contour.py | 114 ++---- src/pan3d/ui/globe.py | 278 ++++---------- src/pan3d/ui/layouts.py | 143 ++++++++ src/pan3d/ui/preview.py | 344 ++++-------------- src/pan3d/ui/slicer.py | 165 ++------- src/pan3d/utils/common.py | 314 ++++++++-------- src/pan3d/viewers/preview.py | 94 +---- src/pan3d/widgets/__init__.py | 27 ++ src/pan3d/widgets/clip_slice_control.py | 118 ++++++ src/pan3d/widgets/data_information.py | 104 ++++++ src/pan3d/widgets/data_origin.py | 90 +++++ src/pan3d/widgets/error_alert.py | 51 +++ src/pan3d/widgets/level_of_detail.py | 105 ++++++ src/pan3d/widgets/pan3d_view.py | 438 +++++++++++++++++++++++ src/pan3d/widgets/save_dataset_dialog.py | 60 ++++ src/pan3d/widgets/scale_control.py | 116 ++++++ src/pan3d/widgets/slice_control.py | 135 +++++++ src/pan3d/widgets/time_navigation.py | 134 +++++++ 22 files changed, 2046 insertions(+), 1367 deletions(-) create mode 100644 src/pan3d/ui/layouts.py create mode 100644 src/pan3d/widgets/clip_slice_control.py create mode 100644 src/pan3d/widgets/data_information.py create mode 100644 src/pan3d/widgets/data_origin.py create mode 100644 src/pan3d/widgets/error_alert.py create mode 100644 src/pan3d/widgets/level_of_detail.py create mode 100644 src/pan3d/widgets/pan3d_view.py create mode 100644 src/pan3d/widgets/save_dataset_dialog.py create mode 100644 src/pan3d/widgets/scale_control.py create mode 100644 src/pan3d/widgets/slice_control.py create mode 100644 src/pan3d/widgets/time_navigation.py diff --git a/src/pan3d/explorers/analytics.py b/src/pan3d/explorers/analytics.py index 05cd5023..3df873f3 100644 --- a/src/pan3d/explorers/analytics.py +++ b/src/pan3d/explorers/analytics.py @@ -25,12 +25,10 @@ ) from pan3d.ui.preview import RenderingSettings from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -373,107 +371,40 @@ def _build_ui(self, **kwargs): self.state.setdefault("time_groups", 0) self.state.setdefault("figure_height", 50) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) + # Use the standard UI creation method + layout = self._create_standard_ui( + panel_label="Analytics Explorer", + view_class=Pan3dAnalyticsView, + rendering_settings_class=RenderingSettings, + view_kwargs={ + "render_window": self.render_window, + "local_rendering": self.local_rendering, + "widgets": [self.widget], + }, + save_path_default="", + error_style="position:absolute;bottom:1rem;right:1rem;", + ) - with v3.VLayout(): - with v3.VMain(style="position: relative"): - with html.Div( - style="position: relative; width: 100%; height: 100%;", - ): - # 3D view - Pan3dAnalyticsView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Analytics Explorer", - ).ui_content: - RenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) - - with v3.VNavigationDrawer( - disable_resize_watcher=True, - disable_route_watcher=True, - permanent=True, - location="right", - v_model=("plot_drawer", False), - width=800, - ): - self.plotting = Plotting( - source=self.source, toggle="chart_expanded" - ) + # Add navigation drawer within the VAppLayout + with layout: + with v3.VNavigationDrawer( + disable_resize_watcher=True, + disable_route_watcher=True, + permanent=True, + location="right", + v_model=("plot_drawer", False), + width=800, + ): + self.plotting = Plotting(source=self.source, toggle="chart_expanded") + + return layout # ----------------------------------------------------- # State change callbacks # ----------------------------------------------------- - @change("color_by") - def _on_color_by_change(self, **kwargs): + @change("color_by", "color_preset", "color_min", "color_max", "nan_color") + def _on_color_properties_change(self, **kwargs): super()._on_color_properties_change(**kwargs) self.plotting.update_plot() diff --git a/src/pan3d/explorers/contour.py b/src/pan3d/explorers/contour.py index dd55ef14..367c5447 100644 --- a/src/pan3d/explorers/contour.py +++ b/src/pan3d/explorers/contour.py @@ -25,13 +25,10 @@ from pan3d.ui.contour import ContourRenderingSettings from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 class ContourExplorer(Explorer): @@ -134,93 +131,34 @@ def _build_ui(self, **_): "scale_z": 0.01, } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Contour Explorer", - ).ui_content: - ContourRenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) + # Use the standard UI creation method + return self._create_standard_ui( + panel_label="Contour Explorer", + view_class=Pan3DView, + rendering_settings_class=ContourRenderingSettings, + view_kwargs={ + "render_window": self.render_window, + "local_rendering": self.local_rendering, + "widgets": [self.widget], + }, + save_path_default="output.nc", + ) def update_rendering(self, reset_camera=False): - self.renderer.ResetCamera() + self.state.dirty_data = False - if self.local_rendering: - self.ctrl.view_update(push_camera=True) + # Ensure actors are visible + if self.actor.GetVisibility() == 0: + self.actor.SetVisibility(1) + if self.actor_lines.GetVisibility() == 0: + self.actor_lines.SetVisibility(1) - self.ctrl.view_reset_camera() + self.renderer.ResetCamera() + + if reset_camera: + self.ctrl.view_reset_camera() + else: + self.ctrl.view_update() # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/explorers/globe.py b/src/pan3d/explorers/globe.py index 7bd9aedf..1fbea1ab 100644 --- a/src/pan3d/explorers/globe.py +++ b/src/pan3d/explorers/globe.py @@ -20,13 +20,10 @@ from pan3d.filters.globe import ProjectToSphere from pan3d.ui.globe import GlobeRenderingSettings from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.globe import get_continent_outlines, get_globe, get_globe_textures -from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 # Prevent view-up warning vtkObject.GlobalWarningDisplayOff() @@ -120,87 +117,21 @@ def _build_ui(self, **kwargs): "render_shadow": False, } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - disable_style_toggle=True, - disable_roll=True, - disable_axis_align=True, - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Globe Explorer", - ).ui_content: - GlobeRenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) + # Use the standard UI creation method + return self._create_standard_ui( + panel_label="Globe Explorer", + view_class=Pan3DView, + rendering_settings_class=GlobeRenderingSettings, + view_kwargs={ + "render_window": self.render_window, + "local_rendering": self.local_rendering, + "widgets": [self.widget], + "disable_style_toggle": True, + "disable_roll": True, + "disable_axis_align": True, + }, + error_max_width=700, + ) # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/explorers/slicer.py b/src/pan3d/explorers/slicer.py index 9c203983..83eb3d4e 100644 --- a/src/pan3d/explorers/slicer.py +++ b/src/pan3d/explorers/slicer.py @@ -26,11 +26,9 @@ from pan3d.ui.slicer import SliceRenderingSettings from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar -from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.utils.common import Explorer from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -77,60 +75,81 @@ def __init__(self, render_window, **kwargs): ) -class SliceSummary(html.Div): - def __init__(self, **kwargs): - super().__init__(**kwargs) - with self: - html.Div( - "{{slice_axis}}", - classes="text-subtitle-1 text-capitalize text-left", - style="transform-origin: 50% 50%; transform: rotate(-90deg) translateX(-100%) translateY(-1rem); position: absolute;", - ) - html.Div( - "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", - classes="text-subtitle-1 mx-1", - ) - v3.VSlider( - v_show="slice_axis === slice_axes[0]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_x",), - min=("bounds[0]",), - max=("bounds[1]",), - ) - v3.VSlider( - v_show="slice_axis === slice_axes[1]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_y",), - min=("bounds[2]",), - max=("bounds[3]",), - ) - v3.VSlider( - v_show="slice_axis === slice_axes[2]", - thumb_label="always", - thumb_size=16, - style="pointer-events: auto;", - hide_details=True, - classes="flex-fill", - direction="vertical", - v_model=("cut_z",), - min=("bounds[4]",), - max=("bounds[5]",), - ) +class SliceSummary(v3.VCard): + def __init__( + self, + axis_names="axis_names", + slice_axis="slice_axis", + cut_x="cut_x", + cut_y="cut_y", + cut_z="cut_z", + bounds="bounds", + **kwargs, + ): + super().__init__( + classes="slice-summary", + rounded="pill", + **kwargs, + ) - html.Div( - "{{parseFloat(bounds[slice_axes.indexOf(slice_axis)*2]).toFixed(2)}}", - classes="text-subtitle-1 mx-1", - ) + with self: + with v3.VRow(classes="align-center mx-2 my-0"): + v3.VSelect( + v_model=(slice_axis,), + items=(axis_names,), + hide_details=True, + density="compact", + variant="solo", + flat=True, + max_width=100, + ) + v3.VSpacer() + html.Span( + "{{parseFloat(cut_x).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[0]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_y).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[1]", + classes="text-subtitle-1", + ) + html.Span( + "{{parseFloat(cut_z).toFixed(2)}}", + v_show=f"{slice_axis} === {axis_names}[2]", + classes="text-subtitle-1", + ) + with v3.VRow(classes="mx-2 my-0"): + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[0]", + v_model=(cut_x,), + min=(f"{bounds}[0]",), + max=(f"{bounds}[1]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[1]", + v_model=(cut_y,), + min=(f"{bounds}[2]",), + max=(f"{bounds}[3]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_show=f"{slice_axis} === {axis_names}[2]", + v_model=(cut_z,), + min=(f"{bounds}[4]",), + max=(f"{bounds}[5]",), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) class SliceExplorer(Explorer): @@ -245,100 +264,42 @@ def _build_ui(self, *args, **kwargs): "slice_axis": "Z", } ) - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DSlicerView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", + # Add SliceSummary as an additional component + def add_slice_summary(): + SliceSummary( v_show="!control_expended", - v_if="color_by", + style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); z-index: 2;", ) - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) + # Use the standard UI creation method + return self._create_standard_ui( + panel_label="Slice Explorer", + view_class=Pan3DSlicerView, + rendering_settings_class=SliceRenderingSettings, + view_kwargs={ + "render_window": self.render_window, + "local_rendering": self.local_rendering, + "widgets": [self.widget], + }, + additional_components=add_slice_summary, + ) - # Sliders overlay - SliceSummary( - v_if="!control_expended", - classes="d-flex align-center flex-column", - style="position: absolute; left: 0; top: 10%; bottom: 10%; z-index: 2; pointer-events: none; min-width: 5rem;", - ) + def update_rendering(self, reset_camera=False): + self.state.dirty_data = False - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label="Slice Explorer", - ).ui_content: - SliceRenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) + # Ensure actors are visible + if self.slice_actor.GetVisibility() == 0: + self.slice_actor.SetVisibility(1) + if self.data_actor.GetVisibility() == 0 and self.state.tdata: + self.data_actor.SetVisibility(1) - def update_rendering(self, reset_camera=False): self.renderer.ResetCamera() - if self.local_rendering: - self.ctrl.view_update(push_camera=True) - - self.ctrl.view_reset_camera() + if reset_camera: + self.ctrl.view_reset_camera() + else: + self.ctrl.view_update() # ------------------------------------------------------------------------- # Property API diff --git a/src/pan3d/ui/contour.py b/src/pan3d/ui/contour.py index f6ccd773..4554a595 100644 --- a/src/pan3d/ui/contour.py +++ b/src/pan3d/ui/contour.py @@ -1,7 +1,6 @@ -import math - from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length +from pan3d.widgets.scale_control import ScaleControl +from pan3d.widgets.time_navigation import TimeNavigation from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -14,62 +13,16 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + ScaleControl( + scale_x_name="scale_x", + scale_y_name="scale_y", + scale_z_name="scale_z", + min_value=0.001, + max_value=100, + step=0.1, + density="compact", + classes="mx-2 my-2", + ) # contours with v3.VTooltip( @@ -93,28 +46,17 @@ def __init__(self, source, update_rendering, **kwargs): ) v3.VDivider() - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${time_idx + 1} / ${slice_t_max+1}`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() + # Update button v3.VBtn( "Update 3D view", block=True, @@ -138,13 +80,7 @@ def update_from_source(self, source=None): state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if hasattr(self.ctx, "time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index 7ffbedef..61239b5d 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,8 +1,8 @@ -import math - from pan3d.utils.common import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.utils.convert import max_str_length +from pan3d.widgets.clip_slice_control import ClipSliceControl +from pan3d.widgets.level_of_detail import LevelOfDetail +from pan3d.widgets.time_navigation import TimeNavigation from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -12,6 +12,7 @@ def __init__(self, source, update_rendering, **kwargs): super().__init__(source, update_rendering, **kwargs) self.source = source + self.state.setdefault("dataset_bounds", [0, 1, 0, 1, 0, 1]) with self.content: v3.VDivider() @@ -115,219 +116,56 @@ def __init__(self, source, update_rendering, **kwargs): v3.VDivider() # X crop/cut - with v3.VTooltip( - v_if="axis_names?.[0]", - text=( - "`${axis_names[0]}: [${dataset_bounds[0]}, ${dataset_bounds[1]}] ${slice_x_type ==='range' ? ('(' + slice_x_range.map((v,i) => v+1).concat(slice_x_step).join(', ') + ')'): slice_x_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[0]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_x_type === 'range'", - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_range", None), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_cut", 0), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_x_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + ClipSliceControl( + axis="x", + axis_name_expr="axis_names?.[0]", + bounds_min_expr="dataset_bounds[0]", + bounds_max_expr="dataset_bounds[1]", + extents_min_expr="slice_extents[axis_names[0]][0]", + extents_max_expr="slice_extents[axis_names[0]][1]", + ) # Y crop/cut - with v3.VTooltip( - v_if="axis_names?.[1]", - text=( - "`${axis_names[1]}: [${dataset_bounds[2]}, ${dataset_bounds[3]}] ${slice_y_type ==='range' ? ('(' + slice_y_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_y_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[1]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_y_type === 'range'", - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_range", None), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_cut", 0), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_y_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + ClipSliceControl( + axis="y", + axis_name_expr="axis_names?.[1]", + bounds_min_expr="dataset_bounds[2]", + bounds_max_expr="dataset_bounds[3]", + extents_min_expr="slice_extents[axis_names[1]][0]", + extents_max_expr="slice_extents[axis_names[1]][1]", + ) # Z crop/cut - with v3.VTooltip( - v_if="axis_names?.[2]", - text=( - "`${axis_names[2]}: [${dataset_bounds[4]}, ${dataset_bounds[5]}] ${slice_z_type ==='range' ? ('(' + slice_z_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_z_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_z_type === 'range'", - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_range", None), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_cut", 0), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_z_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + ClipSliceControl( + axis="z", + axis_name_expr="axis_names?.[2]", + bounds_min_expr="dataset_bounds[4]", + bounds_max_expr="dataset_bounds[5]", + extents_min_expr="slice_extents[axis_names[2]][0]", + extents_max_expr="slice_extents[axis_names[2]][1]", + ) v3.VDivider() - # Slice steps - with v3.VTooltip(text="Level Of Details / Slice stepping"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-stairs", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model_number=("slice_x_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model_number=("slice_y_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model_number=("slice_z_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - # Time slider - with v3.VTooltip( + # Level of detail / Slice steps + LevelOfDetail( + step_x_name="slice_x_step", + step_y_name="slice_y_step", + step_z_name="slice_z_step", + axis_names_var="axis_names", + min_value=1, + classes="mx-2 my-2", + ) + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${t_labels[slice_t]} (${slice_t+1}/${slice_t_max+1})`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() + # Update button v3.VBtn( "Update 3D view", block=True, @@ -350,6 +188,17 @@ def update_from_source(self, source=None): state.color_by = None state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents + + # Update dataset bounds for each axis + bounds = [] + for axis in XYZ: + axis_name = getattr(source, axis) + if axis_name and axis_name in source.slice_extents: + extent = source.slice_extents[axis_name] + bounds.extend([extent[0], extent[1]]) + else: + bounds.extend([0, 1]) + state.dataset_bounds = bounds slices = source.slices for axis in XYZ: # default @@ -374,12 +223,7 @@ def update_from_source(self, source=None): ] # end is inclusive state[f"slice_{axis}_step"] = axis_slice[2] - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if hasattr(self.ctx, "time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/layouts.py b/src/pan3d/ui/layouts.py new file mode 100644 index 00000000..7d91cabb --- /dev/null +++ b/src/pan3d/ui/layouts.py @@ -0,0 +1,143 @@ +"""Standard layout templates for Pan3D explorers.""" + +from pan3d.ui.vtk_view import Pan3DView +from pan3d.utils.common import ControlPanel +from pan3d.widgets.error_alert import ErrorAlert +from pan3d.widgets.save_dataset_dialog import SaveDatasetDialog +from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.time_navigation import TimeNavigation +from trame.ui.vuetify3 import VAppLayout +from trame.widgets import vuetify3 as v3 + + +class StandardExplorerLayout: + """ + Standard layout template for Pan3D explorers. + + This class provides a consistent UI structure for all explorers: + - 3D view with VTK + - Scalar bar + - Save dialog + - Error alerts + - Time navigation + - Control panel with rendering settings + """ + + def __init__( + self, + explorer, + title="Explorer", + view_class=Pan3DView, + view_kwargs=None, + rendering_settings_class=None, + rendering_settings_kwargs=None, + additional_content=None, + ): + """ + Create a standard explorer layout. + + Parameters: + explorer: The explorer instance (must have server, render_window, etc.) + title: Title for the explorer + view_class: Class to use for 3D view (default: Pan3DView) + view_kwargs: Additional kwargs for view class + rendering_settings_class: Class for rendering settings UI + rendering_settings_kwargs: Additional kwargs for rendering settings + additional_content: Function to add additional content (called with layout) + """ + self.explorer = explorer + self.title = title + self.view_class = view_class + self.view_kwargs = view_kwargs or {} + self.rendering_settings_class = rendering_settings_class + self.rendering_settings_kwargs = rendering_settings_kwargs or {} + self.additional_content = additional_content + + def build(self): + """Build and return the standard layout.""" + explorer = self.explorer + explorer.state.trame__title = self.title + + with VAppLayout(explorer.server, fill_height=True) as layout: + explorer.ui = layout + + # 3D view + self.view_class( + explorer.render_window, + local_rendering=explorer.local_rendering, + widgets=[explorer.widget] if hasattr(explorer, "widget") else [], + **self.view_kwargs, + ) + + # Scalar bar + ScalarBar( + ctx_name="scalar_bar", + v_show="!control_expended", + v_if="color_by", + ) + + # Save dialog + explorer.save_dialog = SaveDatasetDialog( + save_callback=explorer._handle_save_dataset_base, + v_model=("show_save_dialog", False), + save_path_model=("save_dataset_path", "dataset.nc"), + title="Save dataset to disk", + ) + + # Error messages + explorer.error_alert = ErrorAlert( + error_key="data_origin_error", + title="Error", + position="fixed", + location="bottom", + ) + + # Time navigation toolbar + with v3.VCard( + v_show="!control_expended", + v_if="slice_t_max > 0", + classes="time-navigation-toolbar", + rounded="pill", + style=( + "position: absolute; bottom: 1rem; left: 50%; " + "transform: translateX(-50%);" + ), + ): + explorer.time_nav_widget = TimeNavigation( + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + ) + + # Additional content (e.g., slice controls, analytics drawer) + if self.additional_content: + self.additional_content(layout) + + # Control panel + with ControlPanel( + enable_data_selection=(explorer.xarray is None), + toggle="control_expended", + load_dataset=explorer.load_dataset, + import_file_upload=explorer.import_file_upload, + export_file_download=explorer.export_state, + xr_update_info="xr_update_info", + panel_label=self.title, + ).ui_content: + if self.rendering_settings_class: + rendering_settings = self.rendering_settings_class( + ctx_name="rendering", + source=explorer.source, + update_rendering=explorer.update_rendering, + **self.rendering_settings_kwargs, + ) + + # If source already has data, update the rendering settings + if ( + explorer.source + and hasattr(explorer.source, "input") + and explorer.source.input is not None + ): + rendering_settings.update_from_source(explorer.source) + + return layout diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index 651a05e1..20032d64 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -1,9 +1,6 @@ -import math - from pan3d.utils.common import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.utils.convert import max_str_length -from trame.widgets import html +from pan3d.widgets import ClipSliceControl, LevelOfDetail, ScaleControl, TimeNavigation from trame.widgets import vuetify3 as v3 @@ -22,278 +19,69 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: v3.VDivider() # X crop/cut - with v3.VTooltip( - v_if="axis_names?.[0]", - text=( - "`${axis_names[0]}: [${dataset_bounds[0]}, ${dataset_bounds[1]}] ${slice_x_type ==='range' ? ('(' + slice_x_range.map((v,i) => v+1).concat(slice_x_step).join(', ') + ')'): slice_x_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[0]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_x_type === 'range'", - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_range", None), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-x-arrow", - v_model=("slice_x_cut", 0), - min=("slice_extents[axis_names[0]][0]",), - max=("slice_extents[axis_names[0]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_x_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + ClipSliceControl( + axis="x", + axis_name_expr="axis_names?.[0]", + bounds_min_expr="dataset_bounds[0]", + bounds_max_expr="dataset_bounds[1]", + extents_min_expr="slice_extents[axis_names[0]][0]", + extents_max_expr="slice_extents[axis_names[0]][1]", + ) # Y crop/cut - with v3.VTooltip( - v_if="axis_names?.[1]", - text=( - "`${axis_names[1]}: [${dataset_bounds[2]}, ${dataset_bounds[3]}] ${slice_y_type ==='range' ? ('(' + slice_y_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_y_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_if="axis_names?.[1]", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_y_type === 'range'", - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_range", None), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-y-arrow", - v_model=("slice_y_cut", 0), - min=("slice_extents[axis_names[1]][0]",), - max=("slice_extents[axis_names[1]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_y_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) + ClipSliceControl( + axis="y", + axis_name_expr="axis_names?.[1]", + bounds_min_expr="dataset_bounds[2]", + bounds_max_expr="dataset_bounds[3]", + extents_min_expr="slice_extents[axis_names[1]][0]", + extents_max_expr="slice_extents[axis_names[1]][1]", + ) # Z crop/cut - with v3.VTooltip( - v_if="axis_names?.[2]", - text=( - "`${axis_names[2]}: [${dataset_bounds[4]}, ${dataset_bounds[5]}] ${slice_z_type ==='range' ? ('(' + slice_z_range.map((v,i) => v+1).join(', ') + ', 1)'): slice_z_cut}`", - ), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex", - v_bind="props", - ): - v3.VRangeSlider( - v_if="slice_z_type === 'range'", - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_range", None), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VSlider( - v_else=True, - prepend_icon="mdi-axis-z-arrow", - v_model=("slice_z_cut", 0), - min=("slice_extents[axis_names[2]][0]",), - max=("slice_extents[axis_names[2]][1]",), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VCheckbox( - v_model=("slice_z_type", "range"), - true_value="range", - false_value="cut", - true_icon="mdi-crop", - false_icon="mdi-box-cutter", - hide_details=True, - density="compact", - size="sm", - classes="mx-2", - ) - v3.VDivider() + ClipSliceControl( + axis="z", + axis_name_expr="axis_names?.[2]", + bounds_min_expr="dataset_bounds[4]", + bounds_max_expr="dataset_bounds[5]", + extents_min_expr="slice_extents[axis_names[2]][0]", + extents_max_expr="slice_extents[axis_names[2]][1]", + ) + + v3.VDivider() # Slice steps - with v3.VTooltip(text="Level Of Details / Slice stepping"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-stairs", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model_number=("slice_x_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model_number=("slice_y_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model_number=("slice_z_step", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=['min="1"'], - type="number", - ) + LevelOfDetail( + step_x_name="slice_x_step", + step_y_name="slice_y_step", + step_z_name="slice_z_step", + axis_names_var="axis_names", + min_value=1, + ) # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + ScaleControl( + scale_x_name="scale_x", + scale_y_name="scale_y", + scale_z_name="scale_z", + axis_names_var="axis_names", + min_value=0.001, + max_value=100, + step=0.1, + density="compact", + classes="mx-2 my-2", + ) - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${t_labels[slice_t]} (${slice_t+1}/${slice_t_max+1})`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) + v3.VDivider() v3.VBtn( "Update 3D view", block=True, @@ -315,6 +103,17 @@ def update_from_source(self, source=None): # state.color_by = None state.axis_names = [source.x, source.y, source.z] state.slice_extents = source.slice_extents + + # Update dataset bounds for each axis + bounds = [] + for axis in XYZ: + axis_name = getattr(source, axis) + if axis_name and axis_name in source.slice_extents: + extent = source.slice_extents[axis_name] + bounds.extend([extent[0], extent[1]]) + else: + bounds.extend([0, 1]) + state.dataset_bounds = bounds slices = source.slices for axis in XYZ: # default @@ -339,12 +138,7 @@ def update_from_source(self, source=None): ] # end is inclusive state[f"slice_{axis}_step"] = axis_slice[2] - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if hasattr(self.ctx, "time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/slicer.py b/src/pan3d/ui/slicer.py index ac0dfe93..374d4097 100644 --- a/src/pan3d/ui/slicer.py +++ b/src/pan3d/ui/slicer.py @@ -1,7 +1,7 @@ -import math - from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length +from pan3d.widgets.scale_control import ScaleControl +from pan3d.widgets.slice_control import SliceControl +from pan3d.widgets.time_navigation import TimeNavigation from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -12,7 +12,6 @@ def __init__(self, source, update_rendering, **kwargs): self.source = source - style = {"density": "compact", "hide_details": True} with self.content: # -- Slice section v3.VDivider() @@ -34,127 +33,41 @@ def __init__(self, source, update_rendering, **kwargs): v_show="slice_axis === axis_names[2]", classes="text-subtitle-1", ) - with v3.VRow(classes="mx-2 my-0"): - v3.VSelect( - v_model=("slice_axis",), - items=("axis_names",), - **style, - ) - with v3.VRow(classes="mx-2 my-0"): - v3.VSlider( - v_show="slice_axis === axis_names[0]", - v_model=("cut_x",), - min=("bounds[0]",), - max=("bounds[1]",), - **style, - ) - v3.VSlider( - v_show="slice_axis === axis_names[1]", - v_model=("cut_y",), - min=("bounds[2]",), - max=("bounds[3]",), - **style, - ) - v3.VSlider( - v_show="slice_axis === axis_names[2]", - v_model=("cut_z",), - min=("bounds[4]",), - max=("bounds[5]",), - **style, - ) - with v3.VRow(classes="mx-2 my-0"): - with v3.VCol(): - html.Div( - "{{parseFloat(bounds[axis_names.indexOf(slice_axis)*2]).toFixed(2)}}", - classes="font-weight-medium", - ) - with v3.VCol(classes="text-right"): - html.Div( - "{{parseFloat(bounds[axis_names.indexOf(slice_axis)*2 + 1]).toFixed(2)}}", - classes="font-weight-medium", - ) + # Use SliceControl widget for the rest + SliceControl( + slice_axis_var="slice_axis", + axis_names_var="axis_names", + cut_x_var="cut_x", + cut_y_var="cut_y", + cut_z_var="cut_z", + bounds_var="bounds", + show_value_display=False, # We already have custom display above + show_bounds=True, + ) v3.VDivider() # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[0]"): - v3.VTextField( - v_model=("scale_x", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[1]"): - v3.VTextField( - v_model=("scale_y", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if="axis_names?.[2]"): - v3.VTextField( - v_model=("scale_z", 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) + ScaleControl( + scale_x_name="scale_x", + scale_y_name="scale_y", + scale_z_name="scale_z", + min_value=0.001, + max_value=100, + step=0.1, + density="compact", + classes="mx-2 my-2", + ) v3.VDivider() - # Time slider - with v3.VTooltip( + # Time navigation + TimeNavigation( v_if="slice_t_max > 0", - text=("`time: ${slice_t + 1} / ${slice_t_max+1}`",), - ): - with html.Template(v_slot_activator="{ props }"): - with html.Div( - classes="d-flex pr-2", - v_bind="props", - ): - v3.VSlider( - prepend_icon="mdi-clock-outline", - v_model=("slice_t", 0), - min=0, - max=("slice_t_max", 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) + index_name="slice_t", + labels_name="t_labels", + labels=[], + ctx_name="time_nav", + classes="mx-2 my-2", + ) v3.VDivider() v3.VBtn( "Update 3D view", @@ -189,16 +102,10 @@ def update_from_source(self, source=None): ] state.slice_extents = source.slice_extents - # Update time - state.slice_t = source.t_index - state.slice_t_max = source.t_size - 1 - state.t_labels = source.t_labels - state.max_time_width = math.ceil(0.58 * max_str_length(state.t_labels)) - - if state.slice_t_max > 0: - state.max_time_index_width = math.ceil( - 0.6 + (math.log10(state.slice_t_max + 1) + 1) * 2 * 0.58 - ) + # Update TimeNavigation widget through context + if hasattr(self.ctx, "time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index # Update state from dataset state.bounds = ds.bounds diff --git a/src/pan3d/utils/common.py b/src/pan3d/utils/common.py index 5010b919..fae56fd7 100644 --- a/src/pan3d/utils/common.py +++ b/src/pan3d/utils/common.py @@ -10,9 +10,16 @@ from pan3d.utils.constants import SLICE_VARS, XYZ from pan3d.utils.convert import update_camera from pan3d.widgets.color_by import ColorBy +from pan3d.widgets.data_information import DataInformation +from pan3d.widgets.data_origin import DataOrigin +from pan3d.widgets.error_alert import ErrorAlert +from pan3d.widgets.pan3d_view import Pan3DView +from pan3d.widgets.save_dataset_dialog import SaveDatasetDialog +from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.app import TrameApp, asynchronous from trame.decorators import change +from trame.ui.vuetify3 import VAppLayout from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -90,6 +97,10 @@ def __init__( ] self.state.nan_color = 2 + # Initialize load button state + self.state.can_load = True + self.state.load_button_text = "Load" + self.ui = None # Initialize source @@ -138,6 +149,13 @@ def _process_cli(self, **_): self.state.show_data_information = True self.ctrl.xr_update_info(self.source.input, self.source.available_arrays) self.ctx.rendering.update_from_source(self.source) + # Initialize xarray tutorial datasets if no dataset is provided + elif self.state.data_origin_source == "xarray": + results, *_ = pan3d_catalogs.search("xarray") + self.state.data_origin_ids = [v["name"] for v in results] + self.state.data_origin_id_to_desc = { + v["name"]: v["description"] for v in results + } def start(self, **kwargs): """Initialize the UI and start the server for XArray Viewer.""" @@ -186,13 +204,20 @@ def _on_data_origin_id(self, data_origin_id, data_origin_source, **kwargs): # ----------------------------------------------------- @change("color_by", "color_preset", "color_min", "color_max", "nan_color") def _on_color_properties_change(self, **_): + if getattr(self, "_updating_color", False): + return # Prevent recursion + if self.mapper: - self.ctx.rendering.color_by.configure_mapper(self.mapper) - self.ctx.scalar_bar.preset = self.state.color_preset - self.ctx.scalar_bar.set_color_range( - self.state.color_min, self.state.color_max - ) - self.ctrl.view_update() + self._updating_color = True + try: + self.ctx.rendering.color_by.configure_mapper(self.mapper) + self.ctx.scalar_bar.preset = self.state.color_preset + self.ctx.scalar_bar.set_color_range( + self.state.color_min, self.state.color_max + ) + self.ctrl.view_update() + finally: + self._updating_color = False @change("slice_t", *[var.format(axis) for axis in XYZ for var in SLICE_VARS]) def on_change(self, slice_t, **_): @@ -419,6 +444,120 @@ def save_dataset(self, file_path): self.state.show_save_dialog = False return asynchronous.create_task(self._save_dataset(file_path)) + def _create_standard_ui( + self, + panel_label, + view_class=None, + rendering_settings_class=None, + rendering_settings_kwargs=None, + view_kwargs=None, + save_path_default="", + error_style="bottom:1rem;right:1rem;", + error_max_width=650, + additional_components=None, + ): + """ + Create the standard UI layout used by all explorers. + + This method provides the common UI structure while allowing customization + through parameters and hooks. + + Parameters: + panel_label: Label for the control panel + view_class: The view class to use (default: Pan3DView) + rendering_settings_class: The rendering settings class to use + rendering_settings_kwargs: Additional kwargs for rendering settings + view_kwargs: Additional kwargs for the view + save_path_default: Default save path for dataset dialog + error_style: Style for error alert positioning + error_max_width: Maximum width for error alert + additional_components: Callable that adds explorer-specific components + + Returns: + The layout object + """ + # Use defaults if not provided + if view_class is None: + view_class = Pan3DView + if view_kwargs is None: + view_kwargs = {} + if rendering_settings_kwargs is None: + rendering_settings_kwargs = {} + + # Create main layout + with VAppLayout(self.server, fill_height=True) as layout: + self.ui = layout + + # 3D View + # Check if view needs standard parameters or custom ones + if view_kwargs and "render_window" in view_kwargs: + # Special case for views that take render_window directly + view_class(**view_kwargs) + else: + # Standard view initialization + view_class( + self.view_update, + self.ctrl, + self.ctx, + v_model=("view_mode", "local"), + **view_kwargs, + ) + + # Scalar Bar + ScalarBar( + ctx_name="scalar_bar", + v_show="!control_expended", + v_if="color_by", + ) + + # Additional components before standard ones (e.g., SliceSummary) + if additional_components: + additional_components() + + # Save Dataset Dialog + SaveDatasetDialog( + save_callback=self.save_dataset, + v_model=("show_save_dialog", False), + save_path_model=("save_dataset_path", save_path_default), + title="Save dataset to disk", + ) + + # Error Alert + ErrorAlert( + error_key="data_origin_error", + title="Failed to load data", + position="absolute" if error_style else None, + style=error_style, + max_width=error_max_width, + ) + + # Summary Toolbar + SummaryToolbar( + v_show="!control_expended", + v_if="slice_t_max > 0", + ) + + # Control Panel + with ControlPanel( + enable_data_selection=(self.xarray is None), + source=self.source, + toggle="control_expended", + load_dataset=self.load_dataset, + import_file_upload=self.import_file_upload, + export_file_download=self.export_state, + xr_update_info="xr_update_info", + panel_label=panel_label, + ).ui_content: + if rendering_settings_class: + rendering_settings_class( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + **rendering_settings_kwargs, + ) + + return layout + class SummaryToolbar(v3.VCard): def __init__( @@ -486,164 +625,7 @@ def __init__( ) -class DataOrigin(CollapsableSection): - def __init__(self, load_dataset): - super().__init__("Data origin", "show_data_origin", True) - - self.state.load_button_text = "Load" - self.state.can_load = True - self.state.data_origin_id_to_desc = {} - - with self.content: - v3.VSelect( - label="Source", - v_model=("data_origin_source", "xarray"), - items=( - "data_origin_sources", - pan3d_catalogs.list_availables(), - ), - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() - v3.VTextField( - placeholder="Location", - v_if="['file', 'url'].includes(data_origin_source)", - v_model=("data_origin_id", ""), - hide_details=True, - density="compact", - flat=True, - variant="solo", - append_inner_icon=( - "data_origin_id_error ? 'mdi-file-document-alert-outline' : undefined", - ), - error=("data_origin_id_error", False), - ) - - with v3.VTooltip( - v_else=True, - text=("`${ data_origin_id_to_desc[data_origin_id] }`",), - ): - with html.Template(v_slot_activator="{ props }"): - v3.VSelect( - v_bind="props", - label="Name", - v_model="data_origin_id", - items=("data_origin_ids", []), - hide_details=True, - density="compact", - flat=True, - variant="solo", - ) - v3.VDivider() - v3.VBtn( - "{{ load_button_text }}", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("!data_origin_id?.length || !can_load",), - color=("can_load ? 'primary': undefined",), - click=( - load_dataset, - "[data_origin_source, data_origin_id, data_origin_order]", - ), - ) - - -class DataInformation(CollapsableSection): - def __init__(self, xarray_info="xarray_info"): - super().__init__("Data information", "show_data_information") - - self._var_name = xarray_info - self.state.setdefault(xarray_info, []) - - with self.content: - with v3.VTable(density="compact", hover=True): - with html.Tbody(): - with html.Template(v_for=f"item, i in {xarray_info}", key="i"): - with v3.VTooltip(): - with html.Template(v_slot_activator="{ props }"): - with html.Tr(v_bind="props", classes="pointer"): - with html.Td( - classes="d-flex align-center text-no-wrap" - ): - v3.VIcon( - "{{ item.icon }}", - size="sm", - classes="mr-2", - ) - html.Div("{{ item.name }}") - html.Td( - "{{ item.length }}", - classes="text-right", - ) - - with v3.VTable( - density="compact", - theme="dark", - classes="no-bg ma-0 pa-0", - ): - with html.Tbody(): - with html.Tr( - v_for="attr, j in item.attrs", - key="j", - ): - html.Td( - "{{ attr.key }}", - ) - html.Td( - "{{ attr.value }}", - ) - - def update_information(self, xr, available_arrays=None): - xarray_info = [] - coords = set(xr.coords.keys()) - data = set(available_arrays or []) - for name in xr.variables: - icon = "mdi-variable" - order = 3 - length = f"({','.join(xr[name].dims)})" - attrs = [] - if name in coords: - icon = "mdi-ruler" - order = 1 - length = xr[name].size - shape = xr[name].shape - if length > 1 and len(shape) == 1: - attrs.append( - { - "key": "range", - "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", - } - ) - if name in data: - icon = "mdi-database" - order = 2 - xarray_info.append( - { - "order": order, - "icon": icon, - "name": name, - "length": length, - "type": str(xr[name].dtype), - "attrs": attrs - + [ - {"key": "type", "value": str(xr[name].dtype)}, - ] - + [ - {"key": str(k), "value": str(v)} - for k, v in xr[name].attrs.items() - ], - } - ) - xarray_info.sort(key=lambda item: item["order"]) - - # Update UI - self.state[self._var_name] = xarray_info +# DataOrigin and DataInformation are now imported from widgets class ControlPanel(v3.VCard): @@ -777,7 +759,9 @@ def __init__( self.ui_content = ui_content if enable_data_selection: DataOrigin(load_dataset) - self.ctrl[xr_update_info] = DataInformation().update_information + + self._data_info = DataInformation() + self.ctrl[xr_update_info] = self._data_info.update_information class RenderingSettingsBasic(CollapsableSection): diff --git a/src/pan3d/viewers/preview.py b/src/pan3d/viewers/preview.py index fae57832..cd2ea8db 100644 --- a/src/pan3d/viewers/preview.py +++ b/src/pan3d/viewers/preview.py @@ -15,13 +15,10 @@ from pan3d.ui.preview import RenderingSettings from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel, Explorer, SummaryToolbar +from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float -from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import vuetify3 as v3 class XArrayViewer(Explorer): @@ -78,83 +75,18 @@ def _setup_vtk(self, pipeline=None): def _build_ui(self, **kwargs): self.state.trame__title = "XArray Viewer" - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D view - Pan3DView( - self.render_window, - local_rendering=self.local_rendering, - widgets=[self.widget], - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - with v3.VDialog(v_model=("show_save_dialog", False)): - with v3.VCard(classes="mx-auto w-50"): - v3.VCardTitle("Save dataset to disk") - v3.VDivider() - with v3.VCardText(): - v3.VTextField( - label="File path to save", - v_model=("save_dataset_path", ""), - hide_details=True, - ) - with v3.VCardActions(): - v3.VSpacer() - v3.VBtn( - "Save", - classes="text-none", - variant="flat", - color="primary", - click=(self.save_dataset, "[save_dataset_path]"), - ) - v3.VBtn( - "Cancel", - classes="text-none", - variant="flat", - click="show_save_dialog=false", - ) - - # Error messages - v3.VAlert( - v_if=("data_origin_error", False), - border="start", - max_width=700, - rounded="lg", - text=("data_origin_error", ""), - title="Failed to load data", - type="error", - variant="tonal", - style="position:absolute;bottom:1rem;right:1rem;", - ) - - # Summary toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - ).ui_content: - RenderingSettings( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - ) + # Use the standard UI creation method + return self._create_standard_ui( + panel_label="XArray Viewer", + view_class=Pan3DView, + rendering_settings_class=RenderingSettings, + view_kwargs={ + "render_window": self.render_window, + "local_rendering": self.local_rendering, + "widgets": [self.widget], + }, + error_max_width=700, + ) # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/widgets/__init__.py b/src/pan3d/widgets/__init__.py index e69de29b..0320b022 100644 --- a/src/pan3d/widgets/__init__.py +++ b/src/pan3d/widgets/__init__.py @@ -0,0 +1,27 @@ +from .clip_slice_control import ClipSliceControl +from .color_by import ColorBy +from .data_information import DataInformation +from .data_origin import DataOrigin +from .error_alert import ErrorAlert +from .level_of_detail import LevelOfDetail +from .pan3d_view import Pan3DView +from .save_dataset_dialog import SaveDatasetDialog +from .scalar_bar import ScalarBar +from .scale_control import ScaleControl +from .slice_control import SliceControl +from .time_navigation import TimeNavigation + +__all__ = [ + "ClipSliceControl", + "ColorBy", + "DataInformation", + "DataOrigin", + "ErrorAlert", + "LevelOfDetail", + "Pan3DView", + "SaveDatasetDialog", + "ScalarBar", + "ScaleControl", + "SliceControl", + "TimeNavigation", +] diff --git a/src/pan3d/widgets/clip_slice_control.py b/src/pan3d/widgets/clip_slice_control.py new file mode 100644 index 00000000..be37e639 --- /dev/null +++ b/src/pan3d/widgets/clip_slice_control.py @@ -0,0 +1,118 @@ +"""Clip Slice Control widget for axis clipping/cropping operations.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ClipSliceControl(html.Div): + """ + A widget for controlling clipping/cropping along a specific axis. + + Provides range selection or single cut value for data clipping. + """ + + _next_id = 0 + + def __init__( + self, + axis, + axis_name_expr, + bounds_min_expr, + bounds_max_expr, + extents_min_expr, + extents_max_expr, + # State variable names (optional) + state_prefix=None, + type_var=None, + range_var=None, + cut_var=None, + step_expr=None, + **kwargs, + ): + """ + Initialize the ClipSliceControl widget. + + Args: + axis: The axis identifier (x, y, z) + axis_name_expr: Expression for axis name (e.g., "axis_names[0]") + bounds_min_expr: Expression for bounds min (e.g., "dataset_bounds[0]") + bounds_max_expr: Expression for bounds max (e.g., "dataset_bounds[1]") + extents_min_expr: Expression for extents min + extents_max_expr: Expression for extents max + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + type_var: State variable for slice type (range/cut) + range_var: State variable for range values + cut_var: State variable for cut value + step_expr: Expression for step value (for tooltip) + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + ClipSliceControl._next_id += 1 + self._id = ClipSliceControl._next_id + ns = state_prefix or f"clip_slice_{self._id}_{axis}" + + # Initialize state variable names + self._state_vars = { + "type": type_var or f"{ns}_type", + "range": range_var or f"{ns}_range", + "cut": cut_var or f"{ns}_cut", + "step": step_expr or f"{ns}_step", + } + + # Use provided variables or defaults for compatibility + type_var = type_var or f"slice_{axis}_type" + range_var = range_var or f"slice_{axis}_range" + cut_var = cut_var or f"slice_{axis}_cut" + step_expr = step_expr or f"slice_{axis}_step" + + with self: + with v3.VTooltip( + v_if=axis_name_expr, + text=( + f"`${{{axis_name_expr}}}: [${{{bounds_min_expr}}}, ${{{bounds_max_expr}}}] " + f"${{{type_var} ==='range' ? ('(' + {range_var}.map((v,i) => v+1).concat({step_expr}).join(', ') + ')'): {cut_var}}}`" + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=axis_name_expr, + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{type_var} === 'range'", + prepend_icon=f"mdi-axis-{axis}-arrow", + v_model=(range_var, None), + min=(extents_min_expr,), + max=(extents_max_expr,), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon=f"mdi-axis-{axis}-arrow", + v_model=(cut_var, 0), + min=(extents_min_expr,), + max=(extents_max_expr,), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(type_var, "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) diff --git a/src/pan3d/widgets/data_information.py b/src/pan3d/widgets/data_information.py new file mode 100644 index 00000000..31722633 --- /dev/null +++ b/src/pan3d/widgets/data_information.py @@ -0,0 +1,104 @@ +"""Data Information widget for displaying dataset metadata.""" + +from pan3d.ui.collapsible import CollapsableSection +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class DataInformation(CollapsableSection): + """ + Data information display widget that matches the original behavior. + + Extends CollapsableSection to provide a collapsible panel for dataset information. + """ + + def __init__(self, xarray_info="xarray_info"): + """Initialize the DataInformation widget.""" + super().__init__("Data information", "show_data_information") + + self._var_name = xarray_info + self.state.setdefault(xarray_info, []) + + with self.content: + with v3.VTable(density="compact", hover=True): + with html.Tbody(): + with html.Template(v_for=f"item, i in {xarray_info}", key="i"): + with v3.VTooltip(): + with html.Template(v_slot_activator="{ props }"): + with html.Tr(v_bind="props", classes="pointer"): + with html.Td( + classes="d-flex align-center text-no-wrap" + ): + v3.VIcon( + "{{ item.icon }}", + size="sm", + classes="mr-2", + ) + html.Div("{{ item.name }}") + html.Td( + "{{ item.length }}", + classes="text-right", + ) + + with v3.VTable( + density="compact", + theme="dark", + classes="no-bg ma-0 pa-0", + ): + with html.Tbody(): + with html.Tr( + v_for="attr, j in item.attrs", + key="j", + ): + html.Td( + "{{ attr.key }}", + ) + html.Td( + "{{ attr.value }}", + ) + + def update_information(self, xr, available_arrays=None): + xarray_info = [] + coords = set(xr.coords.keys()) + data = set(available_arrays or []) + for name in xr.variables: + icon = "mdi-variable" + order = 3 + length = f"({','.join(xr[name].dims)})" + attrs = [] + if name in coords: + icon = "mdi-ruler" + order = 1 + length = xr[name].size + shape = xr[name].shape + if length > 1 and len(shape) == 1: + attrs.append( + { + "key": "range", + "value": f"[{xr[name].values[0]}, {xr[name].values[-1]}]", + } + ) + if name in data: + icon = "mdi-database" + order = 2 + xarray_info.append( + { + "order": order, + "icon": icon, + "name": name, + "length": length, + "type": str(xr[name].dtype), + "attrs": attrs + + [ + {"key": "type", "value": str(xr[name].dtype)}, + ] + + [ + {"key": str(k), "value": str(v)} + for k, v in xr[name].attrs.items() + ], + } + ) + xarray_info.sort(key=lambda item: item["order"]) + + # Update UI + self.state[self._var_name] = xarray_info diff --git a/src/pan3d/widgets/data_origin.py b/src/pan3d/widgets/data_origin.py new file mode 100644 index 00000000..611ec5bd --- /dev/null +++ b/src/pan3d/widgets/data_origin.py @@ -0,0 +1,90 @@ +"""Data Origin widget for selecting and loading data from various sources.""" + +from pan3d import catalogs as pan3d_catalogs +from pan3d.ui.collapsible import CollapsableSection +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class DataOrigin(CollapsableSection): + """ + Data origin selection widget that matches the original behavior. + + Extends CollapsableSection to provide a collapsible panel for data source selection. + + Note: This widget uses fixed state variable names (data_origin_source, data_origin_id, etc.) + instead of the namespace pattern because these variables are accessed across multiple + components in the application. + """ + + def __init__(self, load_dataset): + """ + Initialize the DataOrigin widget. + + Args: + load_dataset: Callback function to load the dataset + """ + super().__init__("Data origin", "show_data_origin", True) + + self.state.load_button_text = "Load" + self.state.can_load = True + self.state.data_origin_id_to_desc = {} + + with self.content: + v3.VSelect( + label="Source", + v_model=("data_origin_source", "xarray"), + items=( + "data_origin_sources", + pan3d_catalogs.list_availables(), + ), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VTextField( + placeholder="Location", + v_if="['file', 'url'].includes(data_origin_source)", + v_model=("data_origin_id", ""), + hide_details=True, + density="compact", + flat=True, + variant="solo", + append_inner_icon=( + "data_origin_id_error ? 'mdi-file-document-alert-outline' : undefined", + ), + error=("data_origin_id_error", False), + ) + + with v3.VTooltip( + v_else=True, + text=("`${ data_origin_id_to_desc[data_origin_id] }`",), + ): + with html.Template(v_slot_activator="{ props }"): + v3.VSelect( + v_bind="props", + label="Name", + v_model="data_origin_id", + items=("data_origin_ids", []), + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VDivider() + v3.VBtn( + "{{ load_button_text }}", + block=True, + classes="text-none", + flat=True, + density="compact", + rounded=0, + disabled=("!data_origin_id?.length || !can_load",), + color=("can_load ? 'primary': undefined",), + click=( + load_dataset, + "[data_origin_source, data_origin_id, data_origin_order]", + ), + ) diff --git a/src/pan3d/widgets/error_alert.py b/src/pan3d/widgets/error_alert.py new file mode 100644 index 00000000..4a5cee1a --- /dev/null +++ b/src/pan3d/widgets/error_alert.py @@ -0,0 +1,51 @@ +"""Error Alert widget for consistent error display across explorers.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ErrorAlert(v3.VAlert): + """ + A reusable error alert component. + + Provides consistent error display across all explorers. + """ + + def __init__( + self, + error_key="data_origin_error", + title="Error", + position="fixed", + location="bottom", + max_width=650, + type="error", + **kwargs, + ): + """ + Initialize the ErrorAlert widget. + + Args: + error_key: State key for error message + title: Alert title + position: CSS position (fixed, absolute, etc.) + location: Vuetify location (bottom, top, etc.) + max_width: Maximum width of alert + type: Alert type (error, warning, info, success) + **kwargs: Additional VAlert properties + """ + super().__init__( + v_model=(error_key, False), + position=position, + location=location, + max_width=max_width, + type=type, + closable=True, + border="start", + **kwargs, + ) + + with self: + with v3.VAlertTitle(): + v3.VIcon("mdi-alert-circle", classes="mr-2") + html.Span(title, classes="text-h6") + html.Div(f"{{{{ {error_key} }}}}", classes="text-body-2 mt-2") diff --git a/src/pan3d/widgets/level_of_detail.py b/src/pan3d/widgets/level_of_detail.py new file mode 100644 index 00000000..5d1493f3 --- /dev/null +++ b/src/pan3d/widgets/level_of_detail.py @@ -0,0 +1,105 @@ +"""Level of Detail control widget for slice stepping.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class LevelOfDetail(html.Div): + """ + A widget for controlling level of detail/slice stepping. + + Provides step controls for X, Y, Z axes. + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + state_prefix=None, + step_x_name=None, + step_y_name=None, + step_z_name=None, + axis_names_var=None, + # UI options + min_value=1, + **kwargs, + ): + """ + Initialize the LevelOfDetail widget. + + Args: + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + step_x_name: State variable name for X step (default: {prefix}_step_x) + step_y_name: State variable name for Y step (default: {prefix}_step_y) + step_z_name: State variable name for Z step (default: {prefix}_step_z) + axis_names_var: State variable containing axis names array (default: {prefix}_axis_names) + min_value: Minimum step value + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + LevelOfDetail._next_id += 1 + self._id = LevelOfDetail._next_id + ns = state_prefix or f"level_of_detail_{self._id}" + + # Initialize state variable names + self._state_vars = { + "step_x": step_x_name or f"{ns}_step_x", + "step_y": step_y_name or f"{ns}_step_y", + "step_z": step_z_name or f"{ns}_step_z", + "axis_names": axis_names_var or f"{ns}_axis_names", + } + + # Use provided variables or defaults for backward compatibility + step_x_name = step_x_name or "slice_x_step" + step_y_name = step_y_name or "slice_y_step" + step_z_name = step_z_name or "slice_z_step" + axis_names_var = axis_names_var or "axis_names" + + with self: + with v3.VTooltip(text="Level Of Details / Slice stepping"): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutters=True, + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + "mdi-stairs", + classes="ml-2 text-medium-emphasis", + ) + with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[0]"): + v3.VTextField( + v_model_number=(step_x_name, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[f'min="{min_value}"'], + type="number", + ) + with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[1]"): + v3.VTextField( + v_model_number=(step_y_name, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[f'min="{min_value}"'], + type="number", + ) + with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[2]"): + v3.VTextField( + v_model_number=(step_z_name, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[f'min="{min_value}"'], + type="number", + ) diff --git a/src/pan3d/widgets/pan3d_view.py b/src/pan3d/widgets/pan3d_view.py new file mode 100644 index 00000000..9457ec4d --- /dev/null +++ b/src/pan3d/widgets/pan3d_view.py @@ -0,0 +1,438 @@ +"""Pan3D View widget for VTK-based 3D visualization.""" + +from vtkmodules.vtkRenderingAnnotation import vtkAxesActor +from vtkmodules.vtkRenderingCore import ( + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, +) + +from pan3d.ui.css import base, vtk_view +from trame.widgets import html +from trame.widgets import vtk as vtk_widgets +from trame.widgets import vuetify3 as v3 + + +class Pan3DView(html.Div): + """ + Self-contained VTK visualization widget with interaction controls. + + Provides: + - VTK render window with local/remote rendering options + - Standard 3D interaction toolbar + - Camera controls and view presets + - Orientation marker widget + + Usage: + view = Pan3DView( + local_rendering="wasm", + on_camera_change=handle_camera_update + ) + view.add_actor(my_vtk_actor) + view.reset_camera() + """ + + _next_id = 0 + + def __init__( + self, + # Rendering options + local_rendering=None, + interactive_ratio=1, + interactive_quality=50, + # State variable names (optional) + view_mode_name=None, + lock_view_name=None, + orientation_widget_name=None, + # Callbacks + on_camera_change=None, + on_view_mode_change=None, + # UI options + show_toolbar=True, + toolbar_position="bottom", + background_color=(0.1, 0.1, 0.1), + # VTK widgets to overlay + widgets=None, + **kwargs, + ): + """ + Create a Pan3D visualization widget. + + Parameters + ---------- + local_rendering : str, optional + "wasm" or "vtkjs" for client-side rendering + interactive_ratio : int + Image downsampling ratio during interaction + interactive_quality : int + JPEG quality during interaction (0-100) + view_mode_name : str, optional + State variable name for 2D/3D mode + lock_view_name : str, optional + State variable name for view lock + orientation_widget_name : str, optional + State variable name for orientation widget visibility + on_camera_change : callable, optional + Callback when camera changes + on_view_mode_change : callable, optional + Callback when 2D/3D mode changes + show_toolbar : bool + Whether to show the interaction toolbar + toolbar_position : str + Toolbar position: "top", "bottom", "left", "right" + background_color : tuple + RGB background color (0-1 range) + widgets : list, optional + List of VTK widgets to add to the view + """ + super().__init__(**kwargs) + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(vtk_view) + + # Generate unique namespace + Pan3DView._next_id += 1 + self._id = Pan3DView._next_id + ns = f"pan3d_view_{self._id}" + + # Store configuration + self.local_rendering = local_rendering + self.interactive_ratio = interactive_ratio + self.interactive_quality = interactive_quality + self.show_toolbar = show_toolbar + self.toolbar_position = toolbar_position + self._on_camera_change = on_camera_change + self._on_view_mode_change = on_view_mode_change + + # Initialize state variables + self.__view_mode = view_mode_name or f"{ns}_view_mode" + self.__lock_view = lock_view_name or f"{ns}_lock_view" + self.__orientation_widget = ( + orientation_widget_name or f"{ns}_orientation_widget" + ) + self.__camera_position = f"{ns}_camera_position" + self.__camera_focal_point = f"{ns}_camera_focal_point" + self.__camera_view_up = f"{ns}_camera_view_up" + + # Set default state + self.state[self.__view_mode] = "3D" + self.state[self.__lock_view] = False + self.state[self.__orientation_widget] = True + + # Initialize VTK objects + self._renderer = vtkRenderer() + self._renderer.SetBackground(*background_color) + + self._render_window = vtkRenderWindow() + self._render_window.AddRenderer(self._renderer) + self._render_window.OffScreenRenderingOn() + + self._interactor = vtkRenderWindowInteractor() + self._interactor.SetRenderWindow(self._render_window) + self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + + # Set up orientation widget + self._setup_orientation_widget() + + # Add any provided widgets + self._widgets = widgets or [] + for widget in self._widgets: + if hasattr(widget, "SetInteractor"): + widget.SetInteractor(self._interactor) + if hasattr(widget, "EnabledOn"): + widget.EnabledOn() + + # Register state callbacks + self.state.change(self.__view_mode)(self._on_view_mode_change) + + # Build UI directly in __init__ + with self: + with html.Div(classes="d-flex flex-column fill-height"): + # Toolbar positioning logic + toolbar_orientation = ( + "vertical" + if self.toolbar_position in ["left", "right"] + else "horizontal" + ) + + # Top toolbar + if self.show_toolbar and self.toolbar_position == "top": + self._toolbar = self._create_toolbar_ui(toolbar_orientation) + + # Main view area + with html.Div(classes="flex-grow-1 d-flex"): + # Left toolbar + if self.show_toolbar and self.toolbar_position == "left": + self._toolbar = self._create_toolbar_ui(toolbar_orientation) + + # VTK render window + if self.local_rendering == "wasm": + # WebAssembly rendering + self._vtk_view = vtk_widgets.VtkLocalView( + render_window=self._render_window, classes="flex-grow-1" + ) + elif self.local_rendering == "vtkjs": + # VTK.js rendering + self._vtk_view = vtk_widgets.VtkLocalView( + render_window=self._render_window, + mode="local", + classes="flex-grow-1", + ) + else: + # Remote rendering + self._vtk_view = vtk_widgets.VtkRemoteView( + render_window=self._render_window, + interactive_ratio=self.interactive_ratio, + interactive_quality=self.interactive_quality, + interactor_events=( + "['LeftButtonPress', 'LeftButtonRelease', 'MouseMove', " + "'RightButtonPress', 'RightButtonRelease', 'MouseWheelForward', " + "'MouseWheelBackward', 'KeyPress']", + ), + classes="flex-grow-1", + StartInteraction=self._start_interaction, + EndInteraction=self._end_interaction, + MouseMove=( + self._on_mouse_move, + "[utils.vtk.event($event)]", + ), + ) + + # Right toolbar + if self.show_toolbar and self.toolbar_position == "right": + self._toolbar = self._create_toolbar_ui(toolbar_orientation) + + # Bottom toolbar + if self.show_toolbar and self.toolbar_position == "bottom": + self._toolbar = self._create_toolbar_ui(toolbar_orientation) + + # ------------------------------------------------------------------------- + # Properties + # ------------------------------------------------------------------------- + + @property + def renderer(self): + """Get the VTK renderer.""" + return self._renderer + + @property + def render_window(self): + """Get the VTK render window.""" + return self._render_window + + @property + def interactor(self): + """Get the VTK interactor.""" + return self._interactor + + @property + def camera(self): + """Get the active camera.""" + return self._renderer.GetActiveCamera() + + @property + def view_mode(self): + """Get/set the view mode (2D/3D).""" + return self.state[self.__view_mode] + + @view_mode.setter + def view_mode(self, value): + with self.state: + self.state[self.__view_mode] = value + + @property + def lock_view(self): + """Get/set the view lock state.""" + return self.state[self.__lock_view] + + @lock_view.setter + def lock_view(self, value): + with self.state: + self.state[self.__lock_view] = bool(value) + + # ------------------------------------------------------------------------- + # UI Building + # ------------------------------------------------------------------------- + + def _create_toolbar_ui(self, orientation): + """Create the interaction toolbar UI.""" + with html.Div(classes=f"pa-1 d-flex align-center {orientation}") as toolbar: + # Lock/unlock view + v3.VBtn( + icon=True, + size="small", + click=(f"{self.__lock_view} = !{self.__lock_view}",), + ).add_child( + v3.VIcon( + f"{{{self.__lock_view} ? 'mdi-lock' : 'mdi-lock-open'}}", + size="small", + ) + ) + + v3.VDivider(vertical=(orientation == "horizontal")) + + # 2D/3D mode + v3.VBtnToggle( + v_model=(self.__view_mode,), + density="compact", + divided=True, + mandatory=True, + ).add_children( + [ + v3.VBtn(value="2D", size="small").add_child("2D"), + v3.VBtn(value="3D", size="small").add_child("3D"), + ] + ) + + v3.VDivider(vertical=(orientation == "horizontal")) + + # Camera presets + v3.VBtn(icon="mdi-home", size="small", click=self.reset_camera) + + v3.VBtn(icon="mdi-rotate-left", size="small", click=self._rotate_left_90) + + v3.VBtn(icon="mdi-rotate-right", size="small", click=self._rotate_right_90) + + v3.VDivider(vertical=(orientation == "horizontal")) + + # Axis alignment + for axis, icon in [ + ("X", "mdi-alpha-x"), + ("Y", "mdi-alpha-y"), + ("Z", "mdi-alpha-z"), + ]: + v3.VBtn( + icon=icon, size="small", click=(self._align_to_axis, f"['{axis}']") + ) + + v3.VDivider(vertical=(orientation == "horizontal")) + + # Orientation widget toggle + v3.VBtn( + icon="mdi-axis-arrow", + size="small", + color=(f"{self.__orientation_widget} ? 'primary' : ''",), + click=(f"{self.__orientation_widget} = !{self.__orientation_widget}",), + ) + + return toolbar + + def _setup_orientation_widget(self): + """Set up the orientation marker widget.""" + axes = vtkAxesActor() + self._orientation_widget = vtk_widgets.vtkOrientationMarkerWidget() + self._orientation_widget.SetOrientationMarker(axes) + self._orientation_widget.SetInteractor(self._interactor) + self._orientation_widget.SetViewport(0.85, 0, 1, 0.15) + self._orientation_widget.EnabledOn() + self._orientation_widget.InteractiveOff() + + # Bind visibility to state + self.state.change(self.__orientation_widget)(self._toggle_orientation_widget) + + # ------------------------------------------------------------------------- + # VTK Operations + # ------------------------------------------------------------------------- + + def add_actor(self, actor): + """Add an actor to the renderer.""" + self._renderer.AddActor(actor) + + def remove_actor(self, actor): + """Remove an actor from the renderer.""" + self._renderer.RemoveActor(actor) + + def clear_actors(self): + """Remove all actors from the renderer.""" + self._renderer.RemoveAllViewProps() + + def reset_camera(self): + """Reset camera to fit all visible objects.""" + self._renderer.ResetCamera() + self.update() + + def update(self): + """Trigger a render update.""" + self._render_window.Render() + if hasattr(self._vtk_view, "update"): + self._vtk_view.update() + self._update_camera_state() + + def set_background(self, r, g, b): + """Set the background color.""" + self._renderer.SetBackground(r, g, b) + self.update() + + # ------------------------------------------------------------------------- + # Event Handlers + # ------------------------------------------------------------------------- + + def _on_view_mode_change(self, mode, **kwargs): + """Handle 2D/3D mode change.""" + if mode == "2D": + self._interactor.GetInteractorStyle().SetCurrentStyleToImage() + else: + self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + + if self._on_view_mode_change: + self._on_view_mode_change(mode) + + self.update() + + def _toggle_orientation_widget(self, visible, **kwargs): + """Toggle orientation widget visibility.""" + if visible: + self._orientation_widget.EnabledOn() + else: + self._orientation_widget.EnabledOff() + self.update() + + def _start_interaction(self): + """Handle start of interaction.""" + + def _end_interaction(self): + """Handle end of interaction.""" + self._update_camera_state() + if self._on_camera_change: + self._on_camera_change(self.camera) + + def _on_mouse_move(self, event): + """Handle mouse move events.""" + + def _update_camera_state(self): + """Update camera state variables.""" + camera = self.camera + with self.state: + self.state[self.__camera_position] = list(camera.GetPosition()) + self.state[self.__camera_focal_point] = list(camera.GetFocalPoint()) + self.state[self.__camera_view_up] = list(camera.GetViewUp()) + + def _rotate_left_90(self): + """Rotate view 90 degrees left.""" + self.camera.Roll(-90) + self.update() + + def _rotate_right_90(self): + """Rotate view 90 degrees right.""" + self.camera.Roll(90) + self.update() + + def _align_to_axis(self, axis): + """Align camera to look along specified axis.""" + self._renderer.ResetCamera() + camera = self.camera + focal = camera.GetFocalPoint() + distance = camera.GetDistance() + + if axis == "X": + camera.SetPosition(focal[0] + distance, focal[1], focal[2]) + camera.SetViewUp(0, 0, 1) + elif axis == "Y": + camera.SetPosition(focal[0], focal[1] + distance, focal[2]) + camera.SetViewUp(0, 0, 1) + elif axis == "Z": + camera.SetPosition(focal[0], focal[1], focal[2] + distance) + camera.SetViewUp(0, 1, 0) + + self.update() diff --git a/src/pan3d/widgets/save_dataset_dialog.py b/src/pan3d/widgets/save_dataset_dialog.py new file mode 100644 index 00000000..8a950a2a --- /dev/null +++ b/src/pan3d/widgets/save_dataset_dialog.py @@ -0,0 +1,60 @@ +"""Save Dataset Dialog widget for consistent save functionality across explorers.""" + +from trame.widgets import vuetify3 as v3 + + +class SaveDatasetDialog(v3.VDialog): + """ + A reusable dialog for saving datasets. + + Used across all explorers to provide consistent save functionality. + """ + + def __init__( + self, + save_callback, + v_model=("show_save_dialog", False), + save_path_model=("save_path", "output.nc"), + title="Save Dataset", + width=500, + **kwargs, + ): + """ + Initialize the SaveDatasetDialog widget. + + Args: + save_callback: Function to call when save is confirmed + v_model: State binding for dialog visibility + save_path_model: State binding for save path + title: Dialog title + width: Dialog width + **kwargs: Additional VDialog properties + """ + super().__init__(v_model=v_model, width=width, **kwargs) + + with self: + with v3.VCard(): + v3.VCardTitle(title) + with v3.VCardText(): + v3.VTextField( + v_model=save_path_model, + label="File Path", + hint="Specify the path where the dataset will be saved", + persistent_hint=True, + prepend_inner_icon="mdi-file", + variant="outlined", + density="compact", + ) + with v3.VCardActions(): + v3.VSpacer() + v3.VBtn( + "Cancel", + click=f"{v_model[0]} = false", + text=True, + ) + v3.VBtn( + "Save", + click=(save_callback, f"[{save_path_model[0]}]"), + variant="elevated", + color="primary", + ) diff --git a/src/pan3d/widgets/scale_control.py b/src/pan3d/widgets/scale_control.py new file mode 100644 index 00000000..8be4c602 --- /dev/null +++ b/src/pan3d/widgets/scale_control.py @@ -0,0 +1,116 @@ +"""Scale Control widget for axis scaling.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ScaleControl(html.Div): + """ + A widget for controlling X, Y, Z scale factors. + + Provides unified scale controls with validation. + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + state_prefix=None, + scale_x_name=None, + scale_y_name=None, + scale_z_name=None, + axis_names_var=None, + # UI options + min_value=0.01, + max_value=100, + step=0.1, + density="compact", + **kwargs, + ): + """ + Initialize the ScaleControl widget. + + Args: + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + scale_x_name: State variable name for X scale (default: {prefix}_scale_x) + scale_y_name: State variable name for Y scale (default: {prefix}_scale_y) + scale_z_name: State variable name for Z scale (default: {prefix}_scale_z) + axis_names_var: State variable name for axis names array (default: {prefix}_axis_names) + min_value: Minimum scale value + max_value: Maximum scale value + step: Scale step increment + density: Vuetify density setting + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + ScaleControl._next_id += 1 + self._id = ScaleControl._next_id + ns = state_prefix or f"scale_control_{self._id}" + + # Initialize state variable names as private instance variables + self.__scale_x = scale_x_name or f"{ns}_scale_x" + self.__scale_y = scale_y_name or f"{ns}_scale_y" + self.__scale_z = scale_z_name or f"{ns}_scale_z" + self.__axis_names = axis_names_var or f"{ns}_axis_names" + + with self: + # Actor scaling + with v3.VTooltip(text="Representation scaling"): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutter=True, + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + "mdi-ruler-square", + classes="ml-2 text-medium-emphasis", + ) + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[0]"): + v3.VTextField( + v_model=(self.__scale_x, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[1]"): + v3.VTextField( + v_model=(self.__scale_y, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[2]"): + v3.VTextField( + v_model=(self.__scale_z, 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=[ + 'pattern="^\d*(\.\d)?$"', + 'min="0.001"', + 'step="0.1"', + ], + type="number", + ) diff --git a/src/pan3d/widgets/slice_control.py b/src/pan3d/widgets/slice_control.py new file mode 100644 index 00000000..7ee032c9 --- /dev/null +++ b/src/pan3d/widgets/slice_control.py @@ -0,0 +1,135 @@ +"""Slice Control widget for single-axis slicing operations.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class SliceControl(html.Div): + """ + A widget for controlling slicing along selectable axes. + + Provides axis selection and position control for slice exploration. + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + state_prefix=None, + slice_axis_var=None, + axis_names_var=None, + cut_x_var=None, + cut_y_var=None, + cut_z_var=None, + bounds_var=None, + # UI options + show_value_display=True, + show_bounds=True, + **kwargs, + ): + """ + Initialize the SliceControl widget. + + Args: + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + slice_axis_var: State variable for selected axis (default: {prefix}_slice_axis) + axis_names_var: State variable for axis names array (default: {prefix}_axis_names) + cut_x_var: State variable for X cut position (default: {prefix}_cut_x) + cut_y_var: State variable for Y cut position (default: {prefix}_cut_y) + cut_z_var: State variable for Z cut position (default: {prefix}_cut_z) + bounds_var: State variable for bounds array (default: {prefix}_bounds) + show_value_display: Whether to show current value + show_bounds: Whether to show min/max bounds + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + SliceControl._next_id += 1 + self._id = SliceControl._next_id + ns = state_prefix or f"slice_control_{self._id}" + + # Initialize state variable names as private instance variables + self.__slice_axis = slice_axis_var or f"{ns}_slice_axis" + self.__axis_names = axis_names_var or f"{ns}_axis_names" + self.__cut_x = cut_x_var or f"{ns}_cut_x" + self.__cut_y = cut_y_var or f"{ns}_cut_y" + self.__cut_z = cut_z_var or f"{ns}_cut_z" + self.__bounds = bounds_var or f"{ns}_bounds" + + # Note: State initialization must be done by the parent explorer/viewer + # Widgets don't have access to state during construction + + style = { + "hide_details": True, + "density": "compact", + "flat": True, + "variant": "solo", + } + + with self: + # Value display + if show_value_display: + with html.Div(classes="d-flex align-center justify-center mb-2"): + html.Span( + f"{{{{parseFloat({self.__cut_x}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[0]", + classes="text-subtitle-1", + ) + html.Span( + f"{{{{parseFloat({self.__cut_y}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[1]", + classes="text-subtitle-1", + ) + html.Span( + f"{{{{parseFloat({self.__cut_z}).toFixed(2)}}}}", + v_show=f"{self.__slice_axis} === {self.__axis_names}[2]", + classes="text-subtitle-1", + ) + + # Axis selector + with v3.VRow(classes="mx-2 my-0"): + v3.VSelect( + v_model=(self.__slice_axis,), + items=(self.__axis_names,), + **style, + ) + + # Sliders for each axis + with v3.VRow(classes="mx-2 my-0"): + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[0]", + v_model=(self.__cut_x,), + min=(f"{self.__bounds}[0]",), + max=(f"{self.__bounds}[1]",), + **style, + ) + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[1]", + v_model=(self.__cut_y,), + min=(f"{self.__bounds}[2]",), + max=(f"{self.__bounds}[3]",), + **style, + ) + v3.VSlider( + v_show=f"{self.__slice_axis} === {self.__axis_names}[2]", + v_model=(self.__cut_z,), + min=(f"{self.__bounds}[4]",), + max=(f"{self.__bounds}[5]",), + **style, + ) + + # Bounds display + if show_bounds: + with v3.VRow(classes="mx-2 my-0"): + with v3.VCol(): + html.Div( + f"{{{{parseFloat({self.__bounds}[{self.__axis_names}.indexOf({self.__slice_axis})*2]).toFixed(2)}}}}", + classes="font-weight-medium", + ) + with v3.VCol(classes="text-right"): + html.Div( + f"{{{{parseFloat({self.__bounds}[{self.__axis_names}.indexOf({self.__slice_axis})*2 + 1]).toFixed(2)}}}}", + classes="font-weight-medium", + ) diff --git a/src/pan3d/widgets/time_navigation.py b/src/pan3d/widgets/time_navigation.py new file mode 100644 index 00000000..8e3b39fd --- /dev/null +++ b/src/pan3d/widgets/time_navigation.py @@ -0,0 +1,134 @@ +"""Time Navigation widget for temporal data exploration.""" + +import math + +from pan3d.ui.css import base, preview +from pan3d.utils.convert import max_str_length +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class TimeNavigation(html.Div): + """ + Presentation widget for navigating through time-based data. + + Provides: + - Time slider with current position + - Time labels display with tooltip + - Index display (current/total) + + Usage: + time_nav = TimeNavigation( + labels=["2020-01-01", "2020-01-02", ...], + index_name="slice_t", + labels_name="t_labels" + ) + """ + + _next_id = 0 + + def __init__( + self, + # State variable names (optional) + index_name=None, + labels_name=None, + labels=None, + **kwargs, + ): + """ + Create a time navigation widget. + + Parameters + ---------- + index_name : str, optional + State variable name for current index + labels_name : str, optional + State variable name for labels array + labels : list, optional + Initial list of time labels (strings) + """ + super().__init__(**kwargs) + + # Activate CSS + self.server.enable_module(base) + self.server.enable_module(preview) + + # Generate unique namespace + TimeNavigation._next_id += 1 + self._id = TimeNavigation._next_id + ns = f"time_nav_{self._id}" + + # Initialize state variables + self.__index = index_name or f"{ns}_index" + self.__labels = labels_name or f"{ns}_labels" + self.__max_index = f"{ns}_max_index" + + # Set default state + self.state[self.__index] = 0 + self.state[self.__labels] = labels if labels is not None else [] + self.state[self.__max_index] = len(labels) - 1 if labels else 0 + + # Build UI directly in __init__ + with self: + with v3.VTooltip( + v_if=f"{self.__max_index} > 0", + text=( + f"`time: ${{{self.__labels}[{self.__index}]}} (${{{self.__index}+1}}/${{{self.__max_index}+1}})`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex pr-2", + v_bind="props", + ): + v3.VSlider( + prepend_icon="mdi-clock-outline", + v_model=(self.__index, 0), + min=0, + max=(self.__max_index, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + + @property + def index(self): + """Get the current time index.""" + return self.state[self.__index] + + @index.setter + def index(self, value): + """Set the current time index.""" + with self.state: + # Ensure index is within valid range + max_index = self.state[self.__max_index] + self.state[self.__index] = max(0, min(int(value), max_index)) + + @property + def labels(self): + """Get the time labels.""" + return self.state[self.__labels] + + @labels.setter + def labels(self, value): + """Set the time labels and update max index.""" + with self.state: + self.state[self.__labels] = list(value) if value else [] + max_index = len(value) - 1 if value else 0 + self.state[self.__max_index] = max_index + + # Also set slice_t_max for backward compatibility + self.state.slice_t_max = max_index + + # Calculate presentation-specific widths + self.state.max_time_width = ( + math.ceil(0.58 * max_str_length(value)) if value else 0 + ) + if max_index > 0: + self.state.max_time_index_width = math.ceil( + 0.6 + (math.log10(max_index + 1) + 1) * 2 * 0.58 + ) + else: + self.state.max_time_index_width = 0 From 1dade464d1ae8e43ebac7ea0cb1e5bb47af93975 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Thu, 7 Aug 2025 14:46:31 -0700 Subject: [PATCH 2/5] refactor: consolidate ClipSliceControl for unified multi-axis handling - Merge separate X/Y/Z controls into single widget instance - Update preview and globe modules to use new widget API --- src/pan3d/ui/globe.py | 38 +---- src/pan3d/ui/preview.py | 35 +--- src/pan3d/widgets/clip_slice_control.py | 213 ++++++++++++++++++------ 3 files changed, 180 insertions(+), 106 deletions(-) diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index 61239b5d..cfadc0c1 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,6 +1,6 @@ from pan3d.utils.common import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.widgets.clip_slice_control import ClipSliceControl +from pan3d.widgets import ClipSliceControl from pan3d.widgets.level_of_detail import LevelOfDetail from pan3d.widgets.time_navigation import TimeNavigation from trame.widgets import html @@ -114,35 +114,15 @@ def __init__(self, source, update_rendering, **kwargs): ) v3.VDivider() - - # X crop/cut - ClipSliceControl( - axis="x", - axis_name_expr="axis_names?.[0]", - bounds_min_expr="dataset_bounds[0]", - bounds_max_expr="dataset_bounds[1]", - extents_min_expr="slice_extents[axis_names[0]][0]", - extents_max_expr="slice_extents[axis_names[0]][1]", - ) - - # Y crop/cut + # Clip/Slice controls for all axes ClipSliceControl( - axis="y", - axis_name_expr="axis_names?.[1]", - bounds_min_expr="dataset_bounds[2]", - bounds_max_expr="dataset_bounds[3]", - extents_min_expr="slice_extents[axis_names[1]][0]", - extents_max_expr="slice_extents[axis_names[1]][1]", - ) - - # Z crop/cut - ClipSliceControl( - axis="z", - axis_name_expr="axis_names?.[2]", - bounds_min_expr="dataset_bounds[4]", - bounds_max_expr="dataset_bounds[5]", - extents_min_expr="slice_extents[axis_names[2]][0]", - extents_max_expr="slice_extents[axis_names[2]][1]", + axis_names_var="axis_names", + dataset_bounds_var="dataset_bounds", + slice_extents_var="slice_extents", + type_vars=["slice_x_type", "slice_y_type", "slice_z_type"], + range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], + cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], + step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], ) v3.VDivider() diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index 20032d64..940a5c21 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -18,34 +18,15 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: v3.VDivider() - # X crop/cut + # Clip/Slice controls for all axes ClipSliceControl( - axis="x", - axis_name_expr="axis_names?.[0]", - bounds_min_expr="dataset_bounds[0]", - bounds_max_expr="dataset_bounds[1]", - extents_min_expr="slice_extents[axis_names[0]][0]", - extents_max_expr="slice_extents[axis_names[0]][1]", - ) - - # Y crop/cut - ClipSliceControl( - axis="y", - axis_name_expr="axis_names?.[1]", - bounds_min_expr="dataset_bounds[2]", - bounds_max_expr="dataset_bounds[3]", - extents_min_expr="slice_extents[axis_names[1]][0]", - extents_max_expr="slice_extents[axis_names[1]][1]", - ) - - # Z crop/cut - ClipSliceControl( - axis="z", - axis_name_expr="axis_names?.[2]", - bounds_min_expr="dataset_bounds[4]", - bounds_max_expr="dataset_bounds[5]", - extents_min_expr="slice_extents[axis_names[2]][0]", - extents_max_expr="slice_extents[axis_names[2]][1]", + axis_names_var="axis_names", + dataset_bounds_var="dataset_bounds", + slice_extents_var="slice_extents", + type_vars=["slice_x_type", "slice_y_type", "slice_z_type"], + range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], + cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], + step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], ) v3.VDivider() diff --git a/src/pan3d/widgets/clip_slice_control.py b/src/pan3d/widgets/clip_slice_control.py index be37e639..14f7d89b 100644 --- a/src/pan3d/widgets/clip_slice_control.py +++ b/src/pan3d/widgets/clip_slice_control.py @@ -1,4 +1,4 @@ -"""Clip Slice Control widget for axis clipping/cropping operations.""" +"""Clip/Slice Control widget for multi-axis data clipping and slicing.""" from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -6,44 +6,39 @@ class ClipSliceControl(html.Div): """ - A widget for controlling clipping/cropping along a specific axis. + A widget for controlling clipping/cropping along all axes (X, Y, Z). - Provides range selection or single cut value for data clipping. + Provides range selection or single cut value for data clipping on each axis. """ _next_id = 0 def __init__( self, - axis, - axis_name_expr, - bounds_min_expr, - bounds_max_expr, - extents_min_expr, - extents_max_expr, # State variable names (optional) state_prefix=None, - type_var=None, - range_var=None, - cut_var=None, - step_expr=None, + axis_names_var=None, + dataset_bounds_var=None, + slice_extents_var=None, + # Optional lists/tuples for custom axis variable names [x, y, z] + type_vars=None, # e.g., ["slice_x_type", "slice_y_type", "slice_z_type"] + range_vars=None, # e.g., ["slice_x_range", "slice_y_range", "slice_z_range"] + cut_vars=None, # e.g., ["slice_x_cut", "slice_y_cut", "slice_z_cut"] + step_vars=None, # e.g., ["slice_x_step", "slice_y_step", "slice_z_step"] **kwargs, ): """ Initialize the ClipSliceControl widget. Args: - axis: The axis identifier (x, y, z) - axis_name_expr: Expression for axis name (e.g., "axis_names[0]") - bounds_min_expr: Expression for bounds min (e.g., "dataset_bounds[0]") - bounds_max_expr: Expression for bounds max (e.g., "dataset_bounds[1]") - extents_min_expr: Expression for extents min - extents_max_expr: Expression for extents max state_prefix: Prefix for state variable names. If not provided, generates unique prefix. - type_var: State variable for slice type (range/cut) - range_var: State variable for range values - cut_var: State variable for cut value - step_expr: Expression for step value (for tooltip) + axis_names_var: State variable for axis names array + dataset_bounds_var: State variable for dataset bounds + slice_extents_var: State variable for slice extents + type_vars: Optional list/tuple of variable names for axis types [x, y, z] + range_vars: Optional list/tuple of variable names for axis ranges [x, y, z] + cut_vars: Optional list/tuple of variable names for axis cut values [x, y, z] + step_vars: Optional list/tuple of variable names for axis steps [x, y, z] **kwargs: Additional div properties """ super().__init__(**kwargs) @@ -51,42 +46,160 @@ def __init__( # Generate unique namespace if not provided ClipSliceControl._next_id += 1 self._id = ClipSliceControl._next_id - ns = state_prefix or f"clip_slice_{self._id}_{axis}" + ns = state_prefix or f"clip_slice_{self._id}" - # Initialize state variable names - self._state_vars = { - "type": type_var or f"{ns}_type", - "range": range_var or f"{ns}_range", - "cut": cut_var or f"{ns}_cut", - "step": step_expr or f"{ns}_step", - } + # Initialize shared state variables + self.__axis_names = axis_names_var or f"{ns}_axis_names" + self.__dataset_bounds = dataset_bounds_var or f"{ns}_dataset_bounds" + self.__slice_extents = slice_extents_var or f"{ns}_slice_extents" - # Use provided variables or defaults for compatibility - type_var = type_var or f"slice_{axis}_type" - range_var = range_var or f"slice_{axis}_range" - cut_var = cut_var or f"slice_{axis}_cut" - step_expr = step_expr or f"slice_{axis}_step" + # Helper function to get variable name from list or use default + def get_var(var_list, index, axis, suffix): + if var_list and len(var_list) > index: + return var_list[index] + return f"{ns}_slice_{axis}_{suffix}" + # Initialize per-axis state variables + axes = ["x", "y", "z"] + self._axis_vars = {} + + for i, axis in enumerate(axes): + self._axis_vars[axis] = { + "type": get_var(type_vars, i, axis, "type"), + "range": get_var(range_vars, i, axis, "range"), + "cut": get_var(cut_vars, i, axis, "cut"), + "step": get_var(step_vars, i, axis, "step"), + "index": i, + } + + # Build UI with self: + # X crop/cut + with v3.VTooltip( + v_if=f"{self.__axis_names}?.[0]", + text=( + f"`${{{self.__axis_names}[0]}}: [${{{self.__dataset_bounds}[0]}}, ${{{self.__dataset_bounds}[1]}}] " + f"${{{self._axis_vars['x']['type']} ==='range' ? " + f"('(' + {self._axis_vars['x']['range']}.map((v,i) => v+1).concat({self._axis_vars['x']['step']}).join(', ') + ')') : " + f"{self._axis_vars['x']['cut']}}}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=f"{self.__axis_names}?.[0]", + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{self._axis_vars['x']['type']} === 'range'", + prepend_icon="mdi-axis-x-arrow", + v_model=(self._axis_vars["x"]["range"], None), + min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-x-arrow", + v_model=(self._axis_vars["x"]["cut"], 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(self._axis_vars["x"]["type"], "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Y crop/cut + with v3.VTooltip( + v_if=f"{self.__axis_names}?.[1]", + text=( + f"`${{{self.__axis_names}[1]}}: [${{{self.__dataset_bounds}[2]}}, ${{{self.__dataset_bounds}[3]}}] " + f"${{{self._axis_vars['y']['type']} ==='range' ? " + f"('(' + {self._axis_vars['y']['range']}.map((v,i) => v+1).concat({self._axis_vars['y']['step']}).join(', ') + ')') : " + f"{self._axis_vars['y']['cut']}}}`", + ), + ): + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=f"{self.__axis_names}?.[1]", + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{self._axis_vars['y']['type']} === 'range'", + prepend_icon="mdi-axis-y-arrow", + v_model=(self._axis_vars["y"]["range"], None), + min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon="mdi-axis-y-arrow", + v_model=(self._axis_vars["y"]["cut"], 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(self._axis_vars["y"]["type"], "range"), + true_value="range", + false_value="cut", + true_icon="mdi-crop", + false_icon="mdi-box-cutter", + hide_details=True, + density="compact", + size="sm", + classes="mx-2", + ) + + # Z crop/cut with v3.VTooltip( - v_if=axis_name_expr, + v_if=f"{self.__axis_names}?.[2]", text=( - f"`${{{axis_name_expr}}}: [${{{bounds_min_expr}}}, ${{{bounds_max_expr}}}] " - f"${{{type_var} ==='range' ? ('(' + {range_var}.map((v,i) => v+1).concat({step_expr}).join(', ') + ')'): {cut_var}}}`" + f"`${{{self.__axis_names}[2]}}: [${{{self.__dataset_bounds}[4]}}, ${{{self.__dataset_bounds}[5]}}] " + f"${{{self._axis_vars['z']['type']} ==='range' ? " + f"('(' + {self._axis_vars['z']['range']}.map((v,i) => v+1).concat({self._axis_vars['z']['step']}).join(', ') + ')') : " + f"{self._axis_vars['z']['cut']}}}`", ), ): with html.Template(v_slot_activator="{ props }"): with html.Div( classes="d-flex", - v_if=axis_name_expr, v_bind="props", + v_if=f"{self.__axis_names}?.[2]", ): v3.VRangeSlider( - v_if=f"{type_var} === 'range'", - prepend_icon=f"mdi-axis-{axis}-arrow", - v_model=(range_var, None), - min=(extents_min_expr,), - max=(extents_max_expr,), + v_if=f"{self._axis_vars['z']['type']} === 'range'", + prepend_icon="mdi-axis-z-arrow", + v_model=(self._axis_vars["z"]["range"], None), + min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), step=1, hide_details=True, density="compact", @@ -95,10 +208,10 @@ def __init__( ) v3.VSlider( v_else=True, - prepend_icon=f"mdi-axis-{axis}-arrow", - v_model=(cut_var, 0), - min=(extents_min_expr,), - max=(extents_max_expr,), + prepend_icon="mdi-axis-z-arrow", + v_model=(self._axis_vars["z"]["cut"], 0), + min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), + max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), step=1, hide_details=True, density="compact", @@ -106,7 +219,7 @@ def __init__( variant="solo", ) v3.VCheckbox( - v_model=(type_var, "range"), + v_model=(self._axis_vars["z"]["type"], "range"), true_value="range", false_value="cut", true_icon="mdi-crop", From 77be1bc09651f908713ead58a633afea3c0cefa6 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 12 Aug 2025 08:02:48 -0700 Subject: [PATCH 3/5] refactor: add VectorPropertyControl widget for unified vector properties - Create new VectorPropertyControl widget for handling X/Y/Z properties - Replace separate LevelOfDetail and ScaleControl widgets - Support scale, step, and other vector properties through single interface --- src/pan3d/ui/contour.py | 16 +- src/pan3d/ui/globe.py | 45 ++---- src/pan3d/ui/preview.py | 54 +++---- src/pan3d/ui/slicer.py | 16 +- src/pan3d/widgets/__init__.py | 6 +- src/pan3d/widgets/clip_slice_control.py | 138 +++++++++++++---- src/pan3d/widgets/level_of_detail.py | 105 ------------- src/pan3d/widgets/scale_control.py | 116 --------------- src/pan3d/widgets/vector_property_control.py | 147 +++++++++++++++++++ 9 files changed, 310 insertions(+), 333 deletions(-) delete mode 100644 src/pan3d/widgets/level_of_detail.py delete mode 100644 src/pan3d/widgets/scale_control.py create mode 100644 src/pan3d/widgets/vector_property_control.py diff --git a/src/pan3d/ui/contour.py b/src/pan3d/ui/contour.py index 4554a595..59ceb7c8 100644 --- a/src/pan3d/ui/contour.py +++ b/src/pan3d/ui/contour.py @@ -1,6 +1,6 @@ from pan3d.utils.common import RenderingSettingsBasic -from pan3d.widgets.scale_control import ScaleControl from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -13,14 +13,18 @@ def __init__(self, source, update_rendering, **kwargs): with self.content: # Actor scaling - ScaleControl( - scale_x_name="scale_x", - scale_y_name="scale_y", - scale_z_name="scale_z", + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", + axis_names_var="axis_names", + default_value=1, min_value=0.001, max_value=100, step=0.1, - density="compact", classes="mx-2 my-2", ) diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index cfadc0c1..8767ea2f 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,8 +1,8 @@ from pan3d.utils.common import RenderingSettingsBasic from pan3d.utils.constants import XYZ from pan3d.widgets import ClipSliceControl -from pan3d.widgets.level_of_detail import LevelOfDetail from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -123,15 +123,20 @@ def __init__(self, source, update_rendering, **kwargs): range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], + ctx_name="clip_slice", ) v3.VDivider() # Level of detail / Slice steps - LevelOfDetail( - step_x_name="slice_x_step", - step_y_name="slice_y_step", - step_z_name="slice_z_step", + VectorPropertyControl( + property_name="step", + icon="mdi-stairs", + tooltip="Level Of Details / Slice stepping", + x_name="slice_x_step", + y_name="slice_y_step", + z_name="slice_z_step", axis_names_var="axis_names", + default_value=1, min_value=1, classes="mx-2 my-2", ) @@ -179,31 +184,9 @@ def update_from_source(self, source=None): else: bounds.extend([0, 1]) state.dataset_bounds = bounds - slices = source.slices - for axis in XYZ: - # default - axis_extent = state.slice_extents.get(getattr(source, axis)) - state[f"slice_{axis}_range"] = axis_extent - state[f"slice_{axis}_cut"] = 0 - state[f"slice_{axis}_step"] = 1 - state[f"slice_{axis}_type"] = "range" - - # use slice info if available - axis_slice = slices.get(getattr(source, axis)) - if axis_slice is not None: - if isinstance(axis_slice, int): - # cut - state[f"slice_{axis}_cut"] = axis_slice - state[f"slice_{axis}_type"] = "cut" - else: - # range - state[f"slice_{axis}_range"] = [ - axis_slice[0], - axis_slice[1] - 1, - ] # end is inclusive - state[f"slice_{axis}_step"] = axis_slice[2] + # Update ClipSliceControl widget through context + self.ctx.clip_slice.update_slice_values(source, source.slices) # Update TimeNavigation widget through context - if hasattr(self.ctx, "time_nav"): - self.ctx.time_nav.labels = source.t_labels - self.ctx.time_nav.index = source.t_index + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index 940a5c21..a7e41312 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -1,6 +1,6 @@ from pan3d.utils.common import RenderingSettingsBasic from pan3d.utils.constants import XYZ -from pan3d.widgets import ClipSliceControl, LevelOfDetail, ScaleControl, TimeNavigation +from pan3d.widgets import ClipSliceControl, TimeNavigation, VectorPropertyControl from trame.widgets import vuetify3 as v3 @@ -27,29 +27,37 @@ def __init__(self, source, update_rendering, **kwargs): range_vars=["slice_x_range", "slice_y_range", "slice_z_range"], cut_vars=["slice_x_cut", "slice_y_cut", "slice_z_cut"], step_vars=["slice_x_step", "slice_y_step", "slice_z_step"], + ctx_name="clip_slice", ) v3.VDivider() - # Slice steps - LevelOfDetail( - step_x_name="slice_x_step", - step_y_name="slice_y_step", - step_z_name="slice_z_step", + # Slice steps / Level of detail + VectorPropertyControl( + property_name="step", + icon="mdi-stairs", + tooltip="Level Of Details / Slice stepping", + x_name="slice_x_step", + y_name="slice_y_step", + z_name="slice_z_step", axis_names_var="axis_names", + default_value=1, min_value=1, ) # Actor scaling - ScaleControl( - scale_x_name="scale_x", - scale_y_name="scale_y", - scale_z_name="scale_z", + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", axis_names_var="axis_names", + default_value=1, min_value=0.001, max_value=100, step=0.1, - density="compact", classes="mx-2 my-2", ) @@ -95,29 +103,9 @@ def update_from_source(self, source=None): else: bounds.extend([0, 1]) state.dataset_bounds = bounds - slices = source.slices - for axis in XYZ: - # default - axis_extent = state.slice_extents.get(getattr(source, axis)) - state[f"slice_{axis}_range"] = axis_extent - state[f"slice_{axis}_cut"] = 0 - state[f"slice_{axis}_step"] = 1 - state[f"slice_{axis}_type"] = "range" - # use slice info if available - axis_slice = slices.get(getattr(source, axis)) - if axis_slice is not None: - if isinstance(axis_slice, int): - # cut - state[f"slice_{axis}_cut"] = axis_slice - state[f"slice_{axis}_type"] = "cut" - else: - # range - state[f"slice_{axis}_range"] = [ - axis_slice[0], - axis_slice[1] - 1, - ] # end is inclusive - state[f"slice_{axis}_step"] = axis_slice[2] + # Update ClipSliceControl widget through context + self.ctx.clip_slice.update_slice_values(source, source.slices) # Update TimeNavigation widget through context if hasattr(self.ctx, "time_nav"): diff --git a/src/pan3d/ui/slicer.py b/src/pan3d/ui/slicer.py index 374d4097..ea4652cb 100644 --- a/src/pan3d/ui/slicer.py +++ b/src/pan3d/ui/slicer.py @@ -1,7 +1,7 @@ from pan3d.utils.common import RenderingSettingsBasic -from pan3d.widgets.scale_control import ScaleControl from pan3d.widgets.slice_control import SliceControl from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -48,14 +48,18 @@ def __init__(self, source, update_rendering, **kwargs): v3.VDivider() # Actor scaling - ScaleControl( - scale_x_name="scale_x", - scale_y_name="scale_y", - scale_z_name="scale_z", + VectorPropertyControl( + property_name="scale", + icon="mdi-ruler-square", + tooltip="Representation scaling", + x_name="scale_x", + y_name="scale_y", + z_name="scale_z", + axis_names_var="axis_names", + default_value=1, min_value=0.001, max_value=100, step=0.1, - density="compact", classes="mx-2 my-2", ) v3.VDivider() diff --git a/src/pan3d/widgets/__init__.py b/src/pan3d/widgets/__init__.py index 0320b022..4176d4be 100644 --- a/src/pan3d/widgets/__init__.py +++ b/src/pan3d/widgets/__init__.py @@ -3,13 +3,12 @@ from .data_information import DataInformation from .data_origin import DataOrigin from .error_alert import ErrorAlert -from .level_of_detail import LevelOfDetail from .pan3d_view import Pan3DView from .save_dataset_dialog import SaveDatasetDialog from .scalar_bar import ScalarBar -from .scale_control import ScaleControl from .slice_control import SliceControl from .time_navigation import TimeNavigation +from .vector_property_control import VectorPropertyControl __all__ = [ "ClipSliceControl", @@ -17,11 +16,10 @@ "DataInformation", "DataOrigin", "ErrorAlert", - "LevelOfDetail", "Pan3DView", "SaveDatasetDialog", "ScalarBar", - "ScaleControl", "SliceControl", "TimeNavigation", + "VectorPropertyControl", ] diff --git a/src/pan3d/widgets/clip_slice_control.py b/src/pan3d/widgets/clip_slice_control.py index 14f7d89b..b16937ce 100644 --- a/src/pan3d/widgets/clip_slice_control.py +++ b/src/pan3d/widgets/clip_slice_control.py @@ -25,6 +25,7 @@ def __init__( range_vars=None, # e.g., ["slice_x_range", "slice_y_range", "slice_z_range"] cut_vars=None, # e.g., ["slice_x_cut", "slice_y_cut", "slice_z_cut"] step_vars=None, # e.g., ["slice_x_step", "slice_y_step", "slice_z_step"] + ctx_name=None, # Context name for programmatic access **kwargs, ): """ @@ -39,6 +40,7 @@ def __init__( range_vars: Optional list/tuple of variable names for axis ranges [x, y, z] cut_vars: Optional list/tuple of variable names for axis cut values [x, y, z] step_vars: Optional list/tuple of variable names for axis steps [x, y, z] + ctx_name: Context name for programmatic access (e.g., "clip_slice") **kwargs: Additional div properties """ super().__init__(**kwargs) @@ -59,18 +61,24 @@ def get_var(var_list, index, axis, suffix): return var_list[index] return f"{ns}_slice_{axis}_{suffix}" - # Initialize per-axis state variables - axes = ["x", "y", "z"] - self._axis_vars = {} + # Initialize per-axis state variables as private instance variables + # X axis + self.__x_type = get_var(type_vars, 0, "x", "type") + self.__x_range = get_var(range_vars, 0, "x", "range") + self.__x_cut = get_var(cut_vars, 0, "x", "cut") + self.__x_step = get_var(step_vars, 0, "x", "step") - for i, axis in enumerate(axes): - self._axis_vars[axis] = { - "type": get_var(type_vars, i, axis, "type"), - "range": get_var(range_vars, i, axis, "range"), - "cut": get_var(cut_vars, i, axis, "cut"), - "step": get_var(step_vars, i, axis, "step"), - "index": i, - } + # Y axis + self.__y_type = get_var(type_vars, 1, "y", "type") + self.__y_range = get_var(range_vars, 1, "y", "range") + self.__y_cut = get_var(cut_vars, 1, "y", "cut") + self.__y_step = get_var(step_vars, 1, "y", "step") + + # Z axis + self.__z_type = get_var(type_vars, 2, "z", "type") + self.__z_range = get_var(range_vars, 2, "z", "range") + self.__z_cut = get_var(cut_vars, 2, "z", "cut") + self.__z_step = get_var(step_vars, 2, "z", "step") # Build UI with self: @@ -79,9 +87,9 @@ def get_var(var_list, index, axis, suffix): v_if=f"{self.__axis_names}?.[0]", text=( f"`${{{self.__axis_names}[0]}}: [${{{self.__dataset_bounds}[0]}}, ${{{self.__dataset_bounds}[1]}}] " - f"${{{self._axis_vars['x']['type']} ==='range' ? " - f"('(' + {self._axis_vars['x']['range']}.map((v,i) => v+1).concat({self._axis_vars['x']['step']}).join(', ') + ')') : " - f"{self._axis_vars['x']['cut']}}}`", + f"${{{self.__x_type} ==='range' ? " + f"('(' + {self.__x_range}.map((v,i) => v+1).concat({self.__x_step}).join(', ') + ')') : " + f"{self.__x_cut}}}`", ), ): with html.Template(v_slot_activator="{ props }"): @@ -91,9 +99,9 @@ def get_var(var_list, index, axis, suffix): v_bind="props", ): v3.VRangeSlider( - v_if=f"{self._axis_vars['x']['type']} === 'range'", + v_if=f"{self.__x_type} === 'range'", prepend_icon="mdi-axis-x-arrow", - v_model=(self._axis_vars["x"]["range"], None), + v_model=(self.__x_range, None), min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), step=1, @@ -105,7 +113,7 @@ def get_var(var_list, index, axis, suffix): v3.VSlider( v_else=True, prepend_icon="mdi-axis-x-arrow", - v_model=(self._axis_vars["x"]["cut"], 0), + v_model=(self.__x_cut, 0), min=(f"{self.__slice_extents}[{self.__axis_names}[0]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[0]][1]",), step=1, @@ -115,7 +123,7 @@ def get_var(var_list, index, axis, suffix): variant="solo", ) v3.VCheckbox( - v_model=(self._axis_vars["x"]["type"], "range"), + v_model=(self.__x_type, "range"), true_value="range", false_value="cut", true_icon="mdi-crop", @@ -131,9 +139,9 @@ def get_var(var_list, index, axis, suffix): v_if=f"{self.__axis_names}?.[1]", text=( f"`${{{self.__axis_names}[1]}}: [${{{self.__dataset_bounds}[2]}}, ${{{self.__dataset_bounds}[3]}}] " - f"${{{self._axis_vars['y']['type']} ==='range' ? " - f"('(' + {self._axis_vars['y']['range']}.map((v,i) => v+1).concat({self._axis_vars['y']['step']}).join(', ') + ')') : " - f"{self._axis_vars['y']['cut']}}}`", + f"${{{self.__y_type} ==='range' ? " + f"('(' + {self.__y_range}.map((v,i) => v+1).concat({self.__y_step}).join(', ') + ')') : " + f"{self.__y_cut}}}`", ), ): with html.Template(v_slot_activator="{ props }"): @@ -143,9 +151,9 @@ def get_var(var_list, index, axis, suffix): v_bind="props", ): v3.VRangeSlider( - v_if=f"{self._axis_vars['y']['type']} === 'range'", + v_if=f"{self.__y_type} === 'range'", prepend_icon="mdi-axis-y-arrow", - v_model=(self._axis_vars["y"]["range"], None), + v_model=(self.__y_range, None), min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), step=1, @@ -157,7 +165,7 @@ def get_var(var_list, index, axis, suffix): v3.VSlider( v_else=True, prepend_icon="mdi-axis-y-arrow", - v_model=(self._axis_vars["y"]["cut"], 0), + v_model=(self.__y_cut, 0), min=(f"{self.__slice_extents}[{self.__axis_names}[1]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[1]][1]",), step=1, @@ -167,7 +175,7 @@ def get_var(var_list, index, axis, suffix): variant="solo", ) v3.VCheckbox( - v_model=(self._axis_vars["y"]["type"], "range"), + v_model=(self.__y_type, "range"), true_value="range", false_value="cut", true_icon="mdi-crop", @@ -183,9 +191,9 @@ def get_var(var_list, index, axis, suffix): v_if=f"{self.__axis_names}?.[2]", text=( f"`${{{self.__axis_names}[2]}}: [${{{self.__dataset_bounds}[4]}}, ${{{self.__dataset_bounds}[5]}}] " - f"${{{self._axis_vars['z']['type']} ==='range' ? " - f"('(' + {self._axis_vars['z']['range']}.map((v,i) => v+1).concat({self._axis_vars['z']['step']}).join(', ') + ')') : " - f"{self._axis_vars['z']['cut']}}}`", + f"${{{self.__z_type} ==='range' ? " + f"('(' + {self.__z_range}.map((v,i) => v+1).concat({self.__z_step}).join(', ') + ')') : " + f"{self.__z_cut}}}`", ), ): with html.Template(v_slot_activator="{ props }"): @@ -195,9 +203,9 @@ def get_var(var_list, index, axis, suffix): v_if=f"{self.__axis_names}?.[2]", ): v3.VRangeSlider( - v_if=f"{self._axis_vars['z']['type']} === 'range'", + v_if=f"{self.__z_type} === 'range'", prepend_icon="mdi-axis-z-arrow", - v_model=(self._axis_vars["z"]["range"], None), + v_model=(self.__z_range, None), min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), step=1, @@ -209,7 +217,7 @@ def get_var(var_list, index, axis, suffix): v3.VSlider( v_else=True, prepend_icon="mdi-axis-z-arrow", - v_model=(self._axis_vars["z"]["cut"], 0), + v_model=(self.__z_cut, 0), min=(f"{self.__slice_extents}[{self.__axis_names}[2]][0]",), max=(f"{self.__slice_extents}[{self.__axis_names}[2]][1]",), step=1, @@ -219,7 +227,7 @@ def get_var(var_list, index, axis, suffix): variant="solo", ) v3.VCheckbox( - v_model=(self._axis_vars["z"]["type"], "range"), + v_model=(self.__z_type, "range"), true_value="range", false_value="cut", true_icon="mdi-crop", @@ -229,3 +237,69 @@ def get_var(var_list, index, axis, suffix): size="sm", classes="mx-2", ) + + # Register in context if ctx_name provided + if ctx_name and hasattr(self, "ctx"): + self.ctx[ctx_name] = self + + def set_axis_type(self, axis, type_value): + """Set the type (range or cut) for a specific axis.""" + if axis == "x": + self.state[self.__x_type] = type_value + elif axis == "y": + self.state[self.__y_type] = type_value + elif axis == "z": + self.state[self.__z_type] = type_value + + def set_axis_range(self, axis, range_value): + """Set the range value for a specific axis.""" + if axis == "x": + self.state[self.__x_range] = range_value + elif axis == "y": + self.state[self.__y_range] = range_value + elif axis == "z": + self.state[self.__z_range] = range_value + + def set_axis_cut(self, axis, cut_value): + """Set the cut value for a specific axis.""" + if axis == "x": + self.state[self.__x_cut] = cut_value + elif axis == "y": + self.state[self.__y_cut] = cut_value + elif axis == "z": + self.state[self.__z_cut] = cut_value + + def set_axis_step(self, axis, step_value): + """Set the step value for a specific axis.""" + if axis == "x": + self.state[self.__x_step] = step_value + elif axis == "y": + self.state[self.__y_step] = step_value + elif axis == "z": + self.state[self.__z_step] = step_value + + def update_slice_values(self, source, slices): + """Update all slice values from source configuration.""" + from pan3d.utils.constants import XYZ + + for axis in XYZ: + # default values + axis_extent = self.state[self.__slice_extents].get(getattr(source, axis)) + self.set_axis_range(axis, axis_extent) + self.set_axis_cut(axis, 0) + self.set_axis_step(axis, 1) + self.set_axis_type(axis, "range") + + # use slice info if available + axis_slice = slices.get(getattr(source, axis)) + if axis_slice is not None: + if isinstance(axis_slice, int): + # cut + self.set_axis_cut(axis, axis_slice) + self.set_axis_type(axis, "cut") + else: + # range + self.set_axis_range( + axis, [axis_slice[0], axis_slice[1] - 1] + ) # end is inclusive + self.set_axis_step(axis, axis_slice[2]) diff --git a/src/pan3d/widgets/level_of_detail.py b/src/pan3d/widgets/level_of_detail.py deleted file mode 100644 index 5d1493f3..00000000 --- a/src/pan3d/widgets/level_of_detail.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Level of Detail control widget for slice stepping.""" - -from trame.widgets import html -from trame.widgets import vuetify3 as v3 - - -class LevelOfDetail(html.Div): - """ - A widget for controlling level of detail/slice stepping. - - Provides step controls for X, Y, Z axes. - """ - - _next_id = 0 - - def __init__( - self, - # State variable names (optional) - state_prefix=None, - step_x_name=None, - step_y_name=None, - step_z_name=None, - axis_names_var=None, - # UI options - min_value=1, - **kwargs, - ): - """ - Initialize the LevelOfDetail widget. - - Args: - state_prefix: Prefix for state variable names. If not provided, generates unique prefix. - step_x_name: State variable name for X step (default: {prefix}_step_x) - step_y_name: State variable name for Y step (default: {prefix}_step_y) - step_z_name: State variable name for Z step (default: {prefix}_step_z) - axis_names_var: State variable containing axis names array (default: {prefix}_axis_names) - min_value: Minimum step value - **kwargs: Additional div properties - """ - super().__init__(**kwargs) - - # Generate unique namespace if not provided - LevelOfDetail._next_id += 1 - self._id = LevelOfDetail._next_id - ns = state_prefix or f"level_of_detail_{self._id}" - - # Initialize state variable names - self._state_vars = { - "step_x": step_x_name or f"{ns}_step_x", - "step_y": step_y_name or f"{ns}_step_y", - "step_z": step_z_name or f"{ns}_step_z", - "axis_names": axis_names_var or f"{ns}_axis_names", - } - - # Use provided variables or defaults for backward compatibility - step_x_name = step_x_name or "slice_x_step" - step_y_name = step_y_name or "slice_y_step" - step_z_name = step_z_name or "slice_z_step" - axis_names_var = axis_names_var or "axis_names" - - with self: - with v3.VTooltip(text="Level Of Details / Slice stepping"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutters=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-stairs", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[0]"): - v3.VTextField( - v_model_number=(step_x_name, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[f'min="{min_value}"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[1]"): - v3.VTextField( - v_model_number=(step_y_name, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[f'min="{min_value}"'], - type="number", - ) - with v3.VCol(classes="pa-0", v_if=f"{axis_names_var}?.[2]"): - v3.VTextField( - v_model_number=(step_z_name, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[f'min="{min_value}"'], - type="number", - ) diff --git a/src/pan3d/widgets/scale_control.py b/src/pan3d/widgets/scale_control.py deleted file mode 100644 index 8be4c602..00000000 --- a/src/pan3d/widgets/scale_control.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Scale Control widget for axis scaling.""" - -from trame.widgets import html -from trame.widgets import vuetify3 as v3 - - -class ScaleControl(html.Div): - """ - A widget for controlling X, Y, Z scale factors. - - Provides unified scale controls with validation. - """ - - _next_id = 0 - - def __init__( - self, - # State variable names (optional) - state_prefix=None, - scale_x_name=None, - scale_y_name=None, - scale_z_name=None, - axis_names_var=None, - # UI options - min_value=0.01, - max_value=100, - step=0.1, - density="compact", - **kwargs, - ): - """ - Initialize the ScaleControl widget. - - Args: - state_prefix: Prefix for state variable names. If not provided, generates unique prefix. - scale_x_name: State variable name for X scale (default: {prefix}_scale_x) - scale_y_name: State variable name for Y scale (default: {prefix}_scale_y) - scale_z_name: State variable name for Z scale (default: {prefix}_scale_z) - axis_names_var: State variable name for axis names array (default: {prefix}_axis_names) - min_value: Minimum scale value - max_value: Maximum scale value - step: Scale step increment - density: Vuetify density setting - **kwargs: Additional div properties - """ - super().__init__(**kwargs) - - # Generate unique namespace if not provided - ScaleControl._next_id += 1 - self._id = ScaleControl._next_id - ns = state_prefix or f"scale_control_{self._id}" - - # Initialize state variable names as private instance variables - self.__scale_x = scale_x_name or f"{ns}_scale_x" - self.__scale_y = scale_y_name or f"{ns}_scale_y" - self.__scale_z = scale_z_name or f"{ns}_scale_z" - self.__axis_names = axis_names_var or f"{ns}_axis_names" - - with self: - # Actor scaling - with v3.VTooltip(text="Representation scaling"): - with html.Template(v_slot_activator="{ props }"): - with v3.VRow( - v_bind="props", - no_gutter=True, - classes="align-center my-0 mx-0 border-b-thin", - ): - v3.VIcon( - "mdi-ruler-square", - classes="ml-2 text-medium-emphasis", - ) - with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[0]"): - v3.VTextField( - v_model=(self.__scale_x, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[1]"): - v3.VTextField( - v_model=(self.__scale_y, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) - with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[2]"): - v3.VTextField( - v_model=(self.__scale_z, 1), - hide_details=True, - density="compact", - flat=True, - variant="solo", - reverse=True, - raw_attrs=[ - 'pattern="^\d*(\.\d)?$"', - 'min="0.001"', - 'step="0.1"', - ], - type="number", - ) diff --git a/src/pan3d/widgets/vector_property_control.py b/src/pan3d/widgets/vector_property_control.py new file mode 100644 index 00000000..96db712f --- /dev/null +++ b/src/pan3d/widgets/vector_property_control.py @@ -0,0 +1,147 @@ +"""Generic Vector Property Control widget for X, Y, Z properties.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class VectorPropertyControl(html.Div): + """ + A generic widget for controlling X, Y, Z vector properties. + + Can be used for scale, step, position, or any other 3D vector property. + """ + + _next_id = 0 + + def __init__( + self, + # Property configuration + property_name="property", + icon="mdi-axis-arrow", + tooltip="Vector property", + # State variable names (optional) + state_prefix=None, + x_name=None, + y_name=None, + z_name=None, + axis_names_var=None, + # Value configuration + default_value=1, + min_value=None, + max_value=None, + step=None, + use_number_model=True, # Default to v_model_number for numeric values + **kwargs, + ): + """ + Initialize the VectorPropertyControl widget. + + Args: + property_name: Base name for the property (e.g., "scale", "step") + icon: Material Design Icon name to display + tooltip: Tooltip text to show on hover + state_prefix: Prefix for state variable names. If not provided, generates unique prefix. + x_name: State variable name for X component (default: {prefix}_{property_name}_x) + y_name: State variable name for Y component (default: {prefix}_{property_name}_y) + z_name: State variable name for Z component (default: {prefix}_{property_name}_z) + axis_names_var: State variable name for axis names array (default: {prefix}_axis_names) + default_value: Default value for all components + min_value: Minimum allowed value + max_value: Maximum allowed value + step: Step increment for number inputs + use_number_model: If True, uses v_model_number; if False, uses v_model (default: True) + **kwargs: Additional div properties + """ + super().__init__(**kwargs) + + # Generate unique namespace if not provided + VectorPropertyControl._next_id += 1 + self._id = VectorPropertyControl._next_id + ns = state_prefix or f"{property_name}_control_{self._id}" + + # Initialize state variable names as private instance variables + self.__x = x_name or f"{ns}_{property_name}_x" + self.__y = y_name or f"{ns}_{property_name}_y" + self.__z = z_name or f"{ns}_{property_name}_z" + self.__axis_names = axis_names_var or f"{ns}_axis_names" + + # Build raw attributes for input validation + raw_attrs = [] + if min_value is not None: + raw_attrs.append(f'min="{min_value}"') + if max_value is not None: + raw_attrs.append(f'max="{max_value}"') + if step is not None: + raw_attrs.append(f'step="{step}"') + + # Determine which v_model to use + v_model_attr = "v_model_number" if use_number_model else "v_model" + + with self: + with v3.VTooltip(text=tooltip): + with html.Template(v_slot_activator="{ props }"): + with v3.VRow( + v_bind="props", + no_gutters=bool(use_number_model), + classes="align-center my-0 mx-0 border-b-thin", + ): + v3.VIcon( + icon, + classes="ml-2 text-medium-emphasis", + ) + # X component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[0]"): + v3.VTextField( + **{v_model_attr: (self.__x, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + # Y component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[1]"): + v3.VTextField( + **{v_model_attr: (self.__y, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + # Z component + with v3.VCol(classes="pa-0", v_if=f"{self.__axis_names}?.[2]"): + v3.VTextField( + **{v_model_attr: (self.__z, default_value)}, + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=raw_attrs if raw_attrs else None, + type="number", + ) + + @property + def x_var(self): + """Get the X component state variable name.""" + return self.__x + + @property + def y_var(self): + """Get the Y component state variable name.""" + return self.__y + + @property + def z_var(self): + """Get the Z component state variable name.""" + return self.__z + + @property + def axis_names_var(self): + """Get the axis names state variable name.""" + return self.__axis_names From 43e463d324d40e18f48655592ddb2c7b1acdf46e Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 12 Aug 2025 15:48:29 -0700 Subject: [PATCH 4/5] refactor: reorganize UI components and clean up unused code - Extract ControlPanel to pan3d.ui.control_panel module - Extract RenderingSettingsBasic to pan3d.ui.rendering_settings module - Create SummaryToolbar widget from common.py code - Remove duplicate imports and unused properties --- src/pan3d/explorers/analytics.py | 36 +- src/pan3d/explorers/contour.py | 30 +- src/pan3d/explorers/globe.py | 28 +- src/pan3d/explorers/slicer.py | 73 +-- src/pan3d/ui/contour.py | 4 +- src/pan3d/ui/control_panel.py | 166 ++++++ src/pan3d/ui/globe.py | 10 +- src/pan3d/ui/layouts.py | 150 ++---- src/pan3d/ui/preview.py | 7 +- src/pan3d/ui/rendering_settings.py | 98 ++++ src/pan3d/ui/slicer.py | 4 +- src/pan3d/utils/common.py | 405 --------------- src/pan3d/viewers/preview.py | 25 +- src/pan3d/widgets/__init__.py | 2 + src/pan3d/widgets/color_by.py | 6 +- src/pan3d/widgets/pan3d_view.py | 622 ++++++++--------------- src/pan3d/widgets/save_dataset_dialog.py | 14 +- src/pan3d/widgets/summary_toolbar.py | 95 ++++ src/trame/widgets/pan3d.py | 15 +- 19 files changed, 746 insertions(+), 1044 deletions(-) create mode 100644 src/pan3d/ui/control_panel.py create mode 100644 src/pan3d/ui/rendering_settings.py create mode 100644 src/pan3d/widgets/summary_toolbar.py diff --git a/src/pan3d/explorers/analytics.py b/src/pan3d/explorers/analytics.py index 3df873f3..d61a6908 100644 --- a/src/pan3d/explorers/analytics.py +++ b/src/pan3d/explorers/analytics.py @@ -23,6 +23,7 @@ plot_options, zonal_axes, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer @@ -371,22 +372,23 @@ def _build_ui(self, **kwargs): self.state.setdefault("time_groups", 0) self.state.setdefault("figure_height", 50) - # Use the standard UI creation method - layout = self._create_standard_ui( - panel_label="Analytics Explorer", - view_class=Pan3dAnalyticsView, - rendering_settings_class=RenderingSettings, - view_kwargs={ - "render_window": self.render_window, - "local_rendering": self.local_rendering, - "widgets": [self.widget], - }, - save_path_default="", - error_style="position:absolute;bottom:1rem;right:1rem;", - ) + ## New way to build UI + with StandardExplorerLayout( + explorer=self, title="Analytics Explorer" + ) as self.ui: + with self.ui.content: + Pan3dAnalyticsView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: + RenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) - # Add navigation drawer within the VAppLayout - with layout: with v3.VNavigationDrawer( disable_resize_watcher=True, disable_route_watcher=True, @@ -397,7 +399,9 @@ def _build_ui(self, **kwargs): ): self.plotting = Plotting(source=self.source, toggle="chart_expanded") - return layout + self.ctx.save_dialog.save_callback = self._save_dataset + if self.source and self.source.input is not None: + self.ctx.rendering.update_from_source(self.source) # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/explorers/contour.py b/src/pan3d/explorers/contour.py index 367c5447..0e0226f0 100644 --- a/src/pan3d/explorers/contour.py +++ b/src/pan3d/explorers/contour.py @@ -24,6 +24,7 @@ ) from pan3d.ui.contour import ContourRenderingSettings +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float @@ -42,10 +43,6 @@ def __init__( input=self.xarray ) # To initialize the pipeline - # setup - self.last_field = None - self.last_preset = None - self._setup_vtk(pipeline) self._build_ui() @@ -131,18 +128,21 @@ def _build_ui(self, **_): "scale_z": 0.01, } ) + # Use the standard UI creation method - return self._create_standard_ui( - panel_label="Contour Explorer", - view_class=Pan3DView, - rendering_settings_class=ContourRenderingSettings, - view_kwargs={ - "render_window": self.render_window, - "local_rendering": self.local_rendering, - "widgets": [self.widget], - }, - save_path_default="output.nc", - ) + with StandardExplorerLayout(explorer=self, title="Contour Explorer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: + ContourRenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) def update_rendering(self, reset_camera=False): self.state.dirty_data = False diff --git a/src/pan3d/explorers/globe.py b/src/pan3d/explorers/globe.py index 1fbea1ab..27feddaa 100644 --- a/src/pan3d/explorers/globe.py +++ b/src/pan3d/explorers/globe.py @@ -19,6 +19,7 @@ from pan3d.filters.globe import ProjectToSphere from pan3d.ui.globe import GlobeRenderingSettings +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.globe import get_continent_outlines, get_globe, get_globe_textures @@ -118,20 +119,19 @@ def _build_ui(self, **kwargs): } ) # Use the standard UI creation method - return self._create_standard_ui( - panel_label="Globe Explorer", - view_class=Pan3DView, - rendering_settings_class=GlobeRenderingSettings, - view_kwargs={ - "render_window": self.render_window, - "local_rendering": self.local_rendering, - "widgets": [self.widget], - "disable_style_toggle": True, - "disable_roll": True, - "disable_axis_align": True, - }, - error_max_width=700, - ) + with StandardExplorerLayout(explorer=self, title="Globe Explorer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + with self.ui.control_panel: + GlobeRenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/explorers/slicer.py b/src/pan3d/explorers/slicer.py index 83eb3d4e..be8c0ff9 100644 --- a/src/pan3d/explorers/slicer.py +++ b/src/pan3d/explorers/slicer.py @@ -24,6 +24,7 @@ vtkRenderWindowInteractor, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.slicer import SliceRenderingSettings from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer @@ -265,25 +266,24 @@ def _build_ui(self, *args, **kwargs): } ) - # Add SliceSummary as an additional component - def add_slice_summary(): - SliceSummary( - v_show="!control_expended", - style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); z-index: 2;", - ) - # Use the standard UI creation method - return self._create_standard_ui( - panel_label="Slice Explorer", - view_class=Pan3DSlicerView, - rendering_settings_class=SliceRenderingSettings, - view_kwargs={ - "render_window": self.render_window, - "local_rendering": self.local_rendering, - "widgets": [self.widget], - }, - additional_components=add_slice_summary, - ) + with StandardExplorerLayout(explorer=self, title="Slice Explorer") as self.ui: + with self.ui.content: + Pan3DSlicerView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widget=[self.widget], + ) + SliceSummary( + v_show="!control_expended", + style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); z-index: 2;", + ) + with self.ui.control_panel: + SliceRenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) def update_rendering(self, reset_camera=False): self.state.dirty_data = False @@ -320,27 +320,6 @@ def slice_axis(self, axis: str) -> None: with self.state: self.state.slice_axis = axis - @property - def slice_value(self): - """ - Returns the value(origin) for the dimension along which the slice - is performed - """ - s = self.state - axis = "xyz"[s.slice_axes.index(s.slice_axis)] - return s[f"cut_{axis}"] - - @slice_value.setter - def slice_value(self, value: float) -> None: - """ - Sets the value(origin) for the dimension along which the slice - is performed - """ - with self.state: - s = self.state - axis = "xyz"[s.slice_axes.index(s.slice_axis)] - s[f"cut_{axis}"] = value - @property def view_mode(self): """ @@ -357,22 +336,6 @@ def view_mode(self, mode): with self.state: self.state.view_mode = mode - @property - def scale_axis(self): - s = self.state - return [s.x_scale, s.y_scale, s.z_scale] - - @scale_axis.setter - def scale_axis(self, sfac): - s = self.state - s.x_scale = float(sfac[0]) - s.y_scale = float(sfac[1]) - s.z_scale = float(sfac[2]) - self.slice_actor.SetScale(*sfac) - self.data_actor.SetScale(*sfac) - self.outline_actor.SetScale(*sfac) - self.on_view_mode_change(s.view_mode) - # ------------------------------------------------------------------------- # UI triggers # ------------------------------------------------------------------------- diff --git a/src/pan3d/ui/contour.py b/src/pan3d/ui/contour.py index 59ceb7c8..09cd9bfd 100644 --- a/src/pan3d/ui/contour.py +++ b/src/pan3d/ui/contour.py @@ -1,4 +1,4 @@ -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.widgets.time_navigation import TimeNavigation from pan3d.widgets.vector_property_control import VectorPropertyControl from trame.widgets import html @@ -85,6 +85,6 @@ def update_from_source(self, source=None): state.slice_extents = source.slice_extents # Update TimeNavigation widget through context - if hasattr(self.ctx, "time_nav"): + if self.ctx.has("time_nav"): self.ctx.time_nav.labels = source.t_labels self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/control_panel.py b/src/pan3d/ui/control_panel.py new file mode 100644 index 00000000..59e54f96 --- /dev/null +++ b/src/pan3d/ui/control_panel.py @@ -0,0 +1,166 @@ +"""Control panel UI component for Pan3D explorers.""" + +from pan3d.widgets.data_information import DataInformation +from pan3d.widgets.data_origin import DataOrigin +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class ControlPanel(v3.VCard): + """ + Control panel component that provides data selection, import/export, + and dataset information display. + + This panel includes: + - Data origin selection (when enabled) + - Import/export state functionality + - Save dataset to disk + - Data information display + """ + + def __init__( + self, + enable_data_selection, + toggle, + load_dataset, + import_file_upload, + export_file_download, + xr_update_info="xr_update_info", + panel_label="XArray Viewer", + **kwargs, + ): + """ + Initialize the ControlPanel. + + Parameters: + enable_data_selection: Whether to show data selection UI + toggle: State variable name for panel expansion toggle + load_dataset: Callback function for loading datasets + import_file_upload: Callback function for importing state files + export_file_download: Callback function for exporting state + xr_update_info: Controller method name for updating data info + panel_label: Label to display in the panel header + **kwargs: Additional arguments passed to VCard + """ + super().__init__( + classes="controller", + rounded=(f"{toggle} || 'circle'",), + **kwargs, + ) + + # state initialization + self.state.import_pending = False + + # extract trigger name + download_export = self.ctrl.trigger_name(export_file_download) + + with self: + with v3.VCardTitle( + classes=( + f"`d-flex pa-1 position-fixed bg-white ${{ {toggle} ? 'controller-content rounded-t border-b-thin':'rounded-circle'}}`", + ), + style="z-index: 1;", + ): + v3.VProgressLinear( + v_if=toggle, + indeterminate=("trame__busy",), + bg_color="rgba(0,0,0,0)", + absolute=True, + color="primary", + location="bottom", + height=2, + ) + v3.VProgressCircular( + v_else=True, + bg_color="rgba(0,0,0,0)", + indeterminate=("trame__busy",), + style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;", + color="primary", + width=3, + ) + v3.VBtn( + icon="mdi-close", + v_if=toggle, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + v3.VBtn( + icon="mdi-menu", + v_else=True, + click=f"{toggle} = !{toggle}", + flat=True, + size="sm", + ) + if self.server.hot_reload: + v3.VBtn( + v_show=toggle, + icon="mdi-refresh", + flat=True, + size="sm", + click=self.ctrl.on_server_reload, + ) + v3.VSpacer() + html.Div( + panel_label, + v_show=toggle, + classes="text-h6 px-2", + ) + v3.VSpacer() + + with v3.VMenu(v_if=toggle, density="compact"): + with html.Template(v_slot_activator="{props}"): + v3.VBtn( + v_bind="props", + icon="mdi-file-arrow-left-right-outline", + flat=True, + size="sm", + classes="mx-1", + ) + with v3.VList(density="compact"): + if enable_data_selection: + with v3.VListItem( + title="Export state file", + disabled=("can_load",), + click=f"utils.download('xarray-state.json', trigger('{download_export}'), 'text/plain')", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon( + "mdi-cloud-download-outline", classes="mr-n5" + ) + + with v3.VListItem( + title="Import state file", + click="trame.utils.get('document').querySelector('#fileImport').click()", + ): + html.Input( + id="fileImport", + hidden=True, + type="file", + change=( + import_file_upload, + "[$event.target.files]", + ), + __events=["change"], + ) + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-cloud-upload-outline", classes="mr-n5") + v3.VDivider() + with v3.VListItem( + title="Save dataset to disk", + disabled=("can_load",), + click="show_save_dialog = true", + ): + with html.Template(v_slot_prepend=True): + v3.VIcon("mdi-file-download-outline", classes="mr-n5") + + with v3.VCardText( + v_show=(toggle, True), + classes="controller-content py-1 mt-10", + ) as ui_content: + self.ui_content = ui_content + if enable_data_selection: + DataOrigin(load_dataset) + + self._data_info = DataInformation() + self.ctrl[xr_update_info] = self._data_info.update_information diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index 8767ea2f..c9e7c900 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,4 +1,4 @@ -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.utils.constants import XYZ from pan3d.widgets import ClipSliceControl from pan3d.widgets.time_navigation import TimeNavigation @@ -186,7 +186,9 @@ def update_from_source(self, source=None): state.dataset_bounds = bounds # Update ClipSliceControl widget through context - self.ctx.clip_slice.update_slice_values(source, source.slices) + if self.ctx.has("clip_slice"): + self.ctx.clip_slice.update_slice_values(source, source.slices) # Update TimeNavigation widget through context - self.ctx.time_nav.labels = source.t_labels - self.ctx.time_nav.index = source.t_index + if self.ctx.has("time_nav"): + self.ctx.time_nav.labels = source.t_labels + self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/layouts.py b/src/pan3d/ui/layouts.py index 7d91cabb..45f91155 100644 --- a/src/pan3d/ui/layouts.py +++ b/src/pan3d/ui/layouts.py @@ -1,16 +1,16 @@ """Standard layout templates for Pan3D explorers.""" -from pan3d.ui.vtk_view import Pan3DView -from pan3d.utils.common import ControlPanel +from pan3d.ui.control_panel import ControlPanel from pan3d.widgets.error_alert import ErrorAlert from pan3d.widgets.save_dataset_dialog import SaveDatasetDialog from pan3d.widgets.scalar_bar import ScalarBar -from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.summary_toolbar import SummaryToolbar from trame.ui.vuetify3 import VAppLayout +from trame.widgets import html from trame.widgets import vuetify3 as v3 -class StandardExplorerLayout: +class StandardExplorerLayout(VAppLayout): """ Standard layout template for Pan3D explorers. @@ -27,11 +27,6 @@ def __init__( self, explorer, title="Explorer", - view_class=Pan3DView, - view_kwargs=None, - rendering_settings_class=None, - rendering_settings_kwargs=None, - additional_content=None, ): """ Create a standard explorer layout. @@ -45,99 +40,58 @@ def __init__( rendering_settings_kwargs: Additional kwargs for rendering settings additional_content: Function to add additional content (called with layout) """ - self.explorer = explorer - self.title = title - self.view_class = view_class - self.view_kwargs = view_kwargs or {} - self.rendering_settings_class = rendering_settings_class - self.rendering_settings_kwargs = rendering_settings_kwargs or {} - self.additional_content = additional_content - - def build(self): - """Build and return the standard layout.""" - explorer = self.explorer - explorer.state.trame__title = self.title - - with VAppLayout(explorer.server, fill_height=True) as layout: - explorer.ui = layout - - # 3D view - self.view_class( - explorer.render_window, - local_rendering=explorer.local_rendering, - widgets=[explorer.widget] if hasattr(explorer, "widget") else [], - **self.view_kwargs, - ) - - # Scalar bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Save dialog - explorer.save_dialog = SaveDatasetDialog( - save_callback=explorer._handle_save_dataset_base, - v_model=("show_save_dialog", False), - save_path_model=("save_dataset_path", "dataset.nc"), - title="Save dataset to disk", - ) + super().__init__(explorer.server, fill_height=True) - # Error messages - explorer.error_alert = ErrorAlert( - error_key="data_origin_error", - title="Error", - position="fixed", - location="bottom", - ) + self.title = title + self.state.trame__title = self.title + + with self: + with v3.VMain(style="position: relative"): + with html.Div( + style="position: relative; width: 100%; height: 100%;", + ) as self.content: + # Scalar bar + ScalarBar( + ctx_name="scalar_bar", + v_show="!control_expended", + v_if="color_by", + ) - # Time navigation toolbar - with v3.VCard( - v_show="!control_expended", - v_if="slice_t_max > 0", - classes="time-navigation-toolbar", - rounded="pill", - style=( - "position: absolute; bottom: 1rem; left: 50%; " - "transform: translateX(-50%);" - ), - ): - explorer.time_nav_widget = TimeNavigation( - index_name="slice_t", - labels_name="t_labels", - labels=[], - ctx_name="time_nav", - ) + # Save dialog + # Expect user to set the save_callback using context obj + SaveDatasetDialog( + ctx_name="save_dialog", + v_model=("show_save_dialog", False), + save_path_model=("save_dataset_path", "dataset.nc"), + title="Save dataset to disk", + ) - # Additional content (e.g., slice controls, analytics drawer) - if self.additional_content: - self.additional_content(layout) + # Error messages + ErrorAlert( + ctx_name="error_alert", + error_key="data_origin_error", + title="Error", + position="fixed", + location="bottom", + ) - # Control panel - with ControlPanel( - enable_data_selection=(explorer.xarray is None), - toggle="control_expended", - load_dataset=explorer.load_dataset, - import_file_upload=explorer.import_file_upload, - export_file_download=explorer.export_state, - xr_update_info="xr_update_info", - panel_label=self.title, - ).ui_content: - if self.rendering_settings_class: - rendering_settings = self.rendering_settings_class( - ctx_name="rendering", - source=explorer.source, - update_rendering=explorer.update_rendering, - **self.rendering_settings_kwargs, + # Summary toolbar + SummaryToolbar( + v_show="!control_expended", + v_if="slice_t_max > 0", ) - # If source already has data, update the rendering settings - if ( - explorer.source - and hasattr(explorer.source, "input") - and explorer.source.input is not None - ): - rendering_settings.update_from_source(explorer.source) + # Control panel + self._control_panel = ControlPanel( + enable_data_selection=(explorer.xarray is None), + toggle="control_expended", + load_dataset=explorer.load_dataset, + import_file_upload=explorer.import_file_upload, + export_file_download=explorer.export_state, + xr_update_info="xr_update_info", + panel_label=self.title, + ) - return layout + @property + def control_panel(self): + return self._control_panel.ui_content diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index a7e41312..c3a41213 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -1,4 +1,4 @@ -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.utils.constants import XYZ from pan3d.widgets import ClipSliceControl, TimeNavigation, VectorPropertyControl from trame.widgets import vuetify3 as v3 @@ -105,9 +105,10 @@ def update_from_source(self, source=None): state.dataset_bounds = bounds # Update ClipSliceControl widget through context - self.ctx.clip_slice.update_slice_values(source, source.slices) + if self.ctx.has("clip_slice"): + self.ctx.clip_slice.update_slice_values(source, source.slices) # Update TimeNavigation widget through context - if hasattr(self.ctx, "time_nav"): + if self.ctx.has("time_nav"): self.ctx.time_nav.labels = source.t_labels self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/ui/rendering_settings.py b/src/pan3d/ui/rendering_settings.py new file mode 100644 index 00000000..4c36ef32 --- /dev/null +++ b/src/pan3d/ui/rendering_settings.py @@ -0,0 +1,98 @@ +"""Basic rendering settings UI component for Pan3D explorers.""" + +import numpy as np + +from pan3d.ui.collapsible import CollapsableSection +from pan3d.widgets.color_by import ColorBy +from trame.decorators import change +from trame.widgets import vuetify3 as v3 + + +class RenderingSettingsBasic(CollapsableSection): + """ + Basic rendering settings component that provides array selection + and color mapping controls. + + This component includes: + - Data array selection + - Color by variable selection + - Color preset and range controls + """ + + def __init__(self, source=None, update_rendering=None, **kwargs): + """ + Initialize the RenderingSettingsBasic component. + + Parameters: + source: VTK source object for data + update_rendering: Callback function to update rendering + **kwargs: Additional arguments passed to CollapsableSection + """ + super().__init__("Rendering", "show_rendering", **kwargs) + self.source = source + + with self.content: + v3.VSelect( + placeholder="Data arrays", + prepend_inner_icon="mdi-database", + hide_selected=True, + v_model=("data_arrays", []), + items=("data_arrays_available", []), + multiple=True, + hide_details=True, + density="compact", + chips=True, + closable_chips=True, + flat=True, + variant="solo", + ) + v3.VDivider() + self.color_by = ColorBy( + color_by_name="color_by", + preset_name="color_preset", + color_min_name="color_min", + color_max_name="color_max", + nan_color_name="nan_color", + reset_color_range=self.reset_color_range, + ) + + def reset_color_range(self): + """Reset the color range to the min and max values of the selected data array.""" + color_by = self.color_by.color_by + ds = self.source() + array = ( + ds.point_data[color_by] + if color_by in ds.point_data.keys() + else ds.cell_data[color_by] + if color_by in ds.cell_data.keys() + else None + ) + if array is not None: + self.color_by.color_min = float(np.min(array)) + self.color_by.color_max = float(np.max(array)) + else: + self.color_by.color_min = 0.0 + self.color_by.color_max = 1.0 + + self.ctrl.view_update() + + @change("data_arrays") + def _on_array_selection(self, data_arrays, **_): + # if self.state.import_pending: + # return + self.state.dirty_data = True + if self.source is not None: + self.source.arrays = data_arrays + + if self.source is None or self.source.input is None: + self.color_by.data_arrays = [] + else: + self.color_by.set_data_arrays_from_vtk(self.source()) + + def update_from_source(self, source=None): + raise NotImplementedError( + """ + This method needs to be implemented in the specialization of this class. + Please override it in the necessary class representing the rendering settings for the Explorer. + """ + ) diff --git a/src/pan3d/ui/slicer.py b/src/pan3d/ui/slicer.py index ea4652cb..15261ef5 100644 --- a/src/pan3d/ui/slicer.py +++ b/src/pan3d/ui/slicer.py @@ -1,4 +1,4 @@ -from pan3d.utils.common import RenderingSettingsBasic +from pan3d.ui.rendering_settings import RenderingSettingsBasic from pan3d.widgets.slice_control import SliceControl from pan3d.widgets.time_navigation import TimeNavigation from pan3d.widgets.vector_property_control import VectorPropertyControl @@ -107,7 +107,7 @@ def update_from_source(self, source=None): state.slice_extents = source.slice_extents # Update TimeNavigation widget through context - if hasattr(self.ctx, "time_nav"): + if self.ctx.has("time_nav"): self.ctx.time_nav.labels = source.t_labels self.ctx.time_nav.index = source.t_index diff --git a/src/pan3d/utils/common.py b/src/pan3d/utils/common.py index fae56fd7..141eb7e1 100644 --- a/src/pan3d/utils/common.py +++ b/src/pan3d/utils/common.py @@ -2,26 +2,12 @@ import traceback from pathlib import Path -import numpy as np - from pan3d import catalogs as pan3d_catalogs -from pan3d.ui.collapsible import CollapsableSection -from pan3d.ui.css import base, preview from pan3d.utils.constants import SLICE_VARS, XYZ from pan3d.utils.convert import update_camera -from pan3d.widgets.color_by import ColorBy -from pan3d.widgets.data_information import DataInformation -from pan3d.widgets.data_origin import DataOrigin -from pan3d.widgets.error_alert import ErrorAlert -from pan3d.widgets.pan3d_view import Pan3DView -from pan3d.widgets.save_dataset_dialog import SaveDatasetDialog -from pan3d.widgets.scalar_bar import ScalarBar from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.app import TrameApp, asynchronous from trame.decorators import change -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import html -from trame.widgets import vuetify3 as v3 class Explorer(TrameApp): @@ -443,394 +429,3 @@ def save_dataset(self, file_path): """ self.state.show_save_dialog = False return asynchronous.create_task(self._save_dataset(file_path)) - - def _create_standard_ui( - self, - panel_label, - view_class=None, - rendering_settings_class=None, - rendering_settings_kwargs=None, - view_kwargs=None, - save_path_default="", - error_style="bottom:1rem;right:1rem;", - error_max_width=650, - additional_components=None, - ): - """ - Create the standard UI layout used by all explorers. - - This method provides the common UI structure while allowing customization - through parameters and hooks. - - Parameters: - panel_label: Label for the control panel - view_class: The view class to use (default: Pan3DView) - rendering_settings_class: The rendering settings class to use - rendering_settings_kwargs: Additional kwargs for rendering settings - view_kwargs: Additional kwargs for the view - save_path_default: Default save path for dataset dialog - error_style: Style for error alert positioning - error_max_width: Maximum width for error alert - additional_components: Callable that adds explorer-specific components - - Returns: - The layout object - """ - # Use defaults if not provided - if view_class is None: - view_class = Pan3DView - if view_kwargs is None: - view_kwargs = {} - if rendering_settings_kwargs is None: - rendering_settings_kwargs = {} - - # Create main layout - with VAppLayout(self.server, fill_height=True) as layout: - self.ui = layout - - # 3D View - # Check if view needs standard parameters or custom ones - if view_kwargs and "render_window" in view_kwargs: - # Special case for views that take render_window directly - view_class(**view_kwargs) - else: - # Standard view initialization - view_class( - self.view_update, - self.ctrl, - self.ctx, - v_model=("view_mode", "local"), - **view_kwargs, - ) - - # Scalar Bar - ScalarBar( - ctx_name="scalar_bar", - v_show="!control_expended", - v_if="color_by", - ) - - # Additional components before standard ones (e.g., SliceSummary) - if additional_components: - additional_components() - - # Save Dataset Dialog - SaveDatasetDialog( - save_callback=self.save_dataset, - v_model=("show_save_dialog", False), - save_path_model=("save_dataset_path", save_path_default), - title="Save dataset to disk", - ) - - # Error Alert - ErrorAlert( - error_key="data_origin_error", - title="Failed to load data", - position="absolute" if error_style else None, - style=error_style, - max_width=error_max_width, - ) - - # Summary Toolbar - SummaryToolbar( - v_show="!control_expended", - v_if="slice_t_max > 0", - ) - - # Control Panel - with ControlPanel( - enable_data_selection=(self.xarray is None), - source=self.source, - toggle="control_expended", - load_dataset=self.load_dataset, - import_file_upload=self.import_file_upload, - export_file_download=self.export_state, - xr_update_info="xr_update_info", - panel_label=panel_label, - ).ui_content: - if rendering_settings_class: - rendering_settings_class( - ctx_name="rendering", - source=self.source, - update_rendering=self.update_rendering, - **rendering_settings_kwargs, - ) - - return layout - - -class SummaryToolbar(v3.VCard): - def __init__( - self, - t_labels="t_labels", - slice_t="slice_t", - slice_t_max="slice_t_max", - color_by="color_by", - data_arrays="data_arrays", - max_time_width="max_time_width", - max_time_index_width="max_time_index_width", - **kwargs, - ): - super().__init__( - classes="summary-toolbar", - rounded="pill", - **kwargs, - ) - - # Activate CSS - self.server.enable_module(base) - self.server.enable_module(preview) - - with self: - with v3.VToolbar( - classes="pl-2", - height=50, - elevation=1, - style="background: none;", - ): - v3.VIcon("mdi-clock-outline") - html.Pre( - f"{{{{ {t_labels}[slice_t] }}}}", - classes="mx-2 text-left", - style=(f"`min-width: ${{ {max_time_width} }}rem;`",), - ) - v3.VSlider( - prepend_inner_icon="mdi-clock-outline", - v_model=(slice_t, 0), - min=0, - max=(slice_t_max, 0), - step=1, - hide_details=True, - density="compact", - flat=True, - variant="solo", - classes="mx-2", - ) - html.Div( - f"{{{{ {slice_t} + 1 }}}}/{{{{ {slice_t_max} + 1 }}}}", - classes="mx-2 text-right", - style=(f"`min-width: ${{ {max_time_index_width} }}rem;`",), - ) - v3.VSelect( - placeholder="Color By", - prepend_inner_icon="mdi-format-color-fill", - v_model=(color_by, None), - items=(data_arrays, []), - clearable=True, - hide_details=True, - density="compact", - flat=True, - variant="solo", - max_width=200, - ) - - -# DataOrigin and DataInformation are now imported from widgets - - -class ControlPanel(v3.VCard): - def __init__( - self, - enable_data_selection, - toggle, - load_dataset, - import_file_upload, - export_file_download, - xr_update_info="xr_update_info", - panel_label="XArray Viewer", - **kwargs, - ): - super().__init__( - classes="controller", - rounded=(f"{toggle} || 'circle'",), - **kwargs, - ) - - # state initialization - self.state.import_pending = False - - # extract trigger name - download_export = self.ctrl.trigger_name(export_file_download) - - with self: - with v3.VCardTitle( - classes=( - f"`d-flex pa-1 position-fixed bg-white ${{ {toggle} ? 'controller-content rounded-t border-b-thin':'rounded-circle'}}`", - ), - style="z-index: 1;", - ): - v3.VProgressLinear( - v_if=toggle, - indeterminate=("trame__busy",), - bg_color="rgba(0,0,0,0)", - absolute=True, - color="primary", - location="bottom", - height=2, - ) - v3.VProgressCircular( - v_else=True, - bg_color="rgba(0,0,0,0)", - indeterminate=("trame__busy",), - style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;", - color="primary", - width=3, - ) - v3.VBtn( - icon="mdi-close", - v_if=toggle, - click=f"{toggle} = !{toggle}", - flat=True, - size="sm", - ) - v3.VBtn( - icon="mdi-menu", - v_else=True, - click=f"{toggle} = !{toggle}", - flat=True, - size="sm", - ) - if self.server.hot_reload: - v3.VBtn( - v_show=toggle, - icon="mdi-refresh", - flat=True, - size="sm", - click=self.ctrl.on_server_reload, - ) - v3.VSpacer() - html.Div( - panel_label, - v_show=toggle, - classes="text-h6 px-2", - ) - v3.VSpacer() - - with v3.VMenu(v_if=toggle, density="compact"): - with html.Template(v_slot_activator="{props}"): - v3.VBtn( - v_bind="props", - icon="mdi-file-arrow-left-right-outline", - flat=True, - size="sm", - classes="mx-1", - ) - with v3.VList(density="compact"): - if enable_data_selection: - with v3.VListItem( - title="Export state file", - disabled=("can_load",), - click=f"utils.download('xarray-state.json', trigger('{download_export}'), 'text/plain')", - ): - with html.Template(v_slot_prepend=True): - v3.VIcon( - "mdi-cloud-download-outline", classes="mr-n5" - ) - - with v3.VListItem( - title="Import state file", - click="trame.utils.get('document').querySelector('#fileImport').click()", - ): - html.Input( - id="fileImport", - hidden=True, - type="file", - change=( - import_file_upload, - "[$event.target.files]", - ), - __events=["change"], - ) - with html.Template(v_slot_prepend=True): - v3.VIcon("mdi-cloud-upload-outline", classes="mr-n5") - v3.VDivider() - with v3.VListItem( - title="Save dataset to disk", - disabled=("can_load",), - click="show_save_dialog = true", - ): - with html.Template(v_slot_prepend=True): - v3.VIcon("mdi-file-download-outline", classes="mr-n5") - - with v3.VCardText( - v_show=(toggle, True), - classes="controller-content py-1 mt-10", - ) as ui_content: - self.ui_content = ui_content - if enable_data_selection: - DataOrigin(load_dataset) - - self._data_info = DataInformation() - self.ctrl[xr_update_info] = self._data_info.update_information - - -class RenderingSettingsBasic(CollapsableSection): - def __init__(self, source=None, update_rendering=None, **kwargs): - super().__init__("Rendering", "show_rendering", **kwargs) - self.source = source - - with self.content: - v3.VSelect( - placeholder="Data arrays", - prepend_inner_icon="mdi-database", - hide_selected=True, - v_model=("data_arrays", []), - items=("data_arrays_available", []), - multiple=True, - hide_details=True, - density="compact", - chips=True, - closable_chips=True, - flat=True, - variant="solo", - ) - v3.VDivider() - self.color_by = ColorBy( - color_by_name="color_by", - preset_name="color_preset", - color_min_name="color_min", - color_max_name="color_max", - nan_color_name="nan_color", - reset_color_range=self.reset_color_range, - ) - - def reset_color_range(self): - """Reset the color range to the min and max values of the selected data array.""" - color_by = self.color_by.color_by - ds = self.source() - array = ( - ds.point_data[color_by] - if color_by in ds.point_data.keys() - else ds.cell_data[color_by] - if color_by in ds.cell_data.keys() - else None - ) - if array is not None: - self.color_by.color_min = float(np.min(array)) - self.color_by.color_max = float(np.max(array)) - else: - self.color_by.color_min = 0.0 - self.color_by.color_max = 1.0 - - self.ctrl.view_update() - - @change("data_arrays") - def _on_array_selection(self, data_arrays, **_): - # if self.state.import_pending: - # return - self.state.dirty_data = True - if self.source is not None: - self.source.arrays = data_arrays - - if self.source is None or self.source.input is None: - self.color_by.data_arrays = [] - else: - self.color_by.set_data_arrays_from_vtk(self.source()) - - def update_from_source(self, source=None): - raise NotImplementedError( - """ - This method needs to be implemented in the specialization of this class. - Please override it in the necessary class representing the rendering settings for the Explorer. - """ - ) diff --git a/src/pan3d/viewers/preview.py b/src/pan3d/viewers/preview.py index cd2ea8db..9b6461aa 100644 --- a/src/pan3d/viewers/preview.py +++ b/src/pan3d/viewers/preview.py @@ -13,6 +13,7 @@ vtkRenderWindowInteractor, ) +from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer @@ -76,17 +77,19 @@ def _build_ui(self, **kwargs): self.state.trame__title = "XArray Viewer" # Use the standard UI creation method - return self._create_standard_ui( - panel_label="XArray Viewer", - view_class=Pan3DView, - rendering_settings_class=RenderingSettings, - view_kwargs={ - "render_window": self.render_window, - "local_rendering": self.local_rendering, - "widgets": [self.widget], - }, - error_max_width=700, - ) + with StandardExplorerLayout(explorer=self, title="XArray Viewer") as self.ui: + with self.ui.content: + Pan3DView( + render_window=self.render_window, + local_rendering=self.local_rendering, + widgets=[self.widget], + ) + with self.ui.control_panel: + RenderingSettings( + ctx_name="rendering", + source=self.source, + update_rendering=self.update_rendering, + ) # ----------------------------------------------------- # State change callbacks diff --git a/src/pan3d/widgets/__init__.py b/src/pan3d/widgets/__init__.py index 4176d4be..f57f7474 100644 --- a/src/pan3d/widgets/__init__.py +++ b/src/pan3d/widgets/__init__.py @@ -7,6 +7,7 @@ from .save_dataset_dialog import SaveDatasetDialog from .scalar_bar import ScalarBar from .slice_control import SliceControl +from .summary_toolbar import SummaryToolbar from .time_navigation import TimeNavigation from .vector_property_control import VectorPropertyControl @@ -20,6 +21,7 @@ "SaveDatasetDialog", "ScalarBar", "SliceControl", + "SummaryToolbar", "TimeNavigation", "VectorPropertyControl", ] diff --git a/src/pan3d/widgets/color_by.py b/src/pan3d/widgets/color_by.py index 87604d74..b45a4f2c 100644 --- a/src/pan3d/widgets/color_by.py +++ b/src/pan3d/widgets/color_by.py @@ -315,8 +315,7 @@ def color_min(self): @color_min.setter def color_min(self, value): """Set the minimum value of the color mapping range.""" - with self.state: - self.state[self.__color_min] = value + self.state[self.__color_min] = value @property def color_min_name(self): @@ -329,8 +328,7 @@ def color_max(self): @color_max.setter def color_max(self, value): """Set the maximum value of the color mapping range.""" - with self.state: - self.state[self.__color_max] = value + self.state[self.__color_max] = value @property def color_max_name(self): diff --git a/src/pan3d/widgets/pan3d_view.py b/src/pan3d/widgets/pan3d_view.py index 9457ec4d..b0cbcf70 100644 --- a/src/pan3d/widgets/pan3d_view.py +++ b/src/pan3d/widgets/pan3d_view.py @@ -1,438 +1,236 @@ -"""Pan3D View widget for VTK-based 3D visualization.""" - -from vtkmodules.vtkRenderingAnnotation import vtkAxesActor -from vtkmodules.vtkRenderingCore import ( - vtkRenderer, - vtkRenderWindow, - vtkRenderWindowInteractor, -) - from pan3d.ui.css import base, vtk_view +from pan3d.utils.constants import VIEW_UPS +from trame.decorators import TrameApp, change from trame.widgets import html -from trame.widgets import vtk as vtk_widgets +from trame.widgets import vtk as vtkw +from trame.widgets import vtklocal as wasm from trame.widgets import vuetify3 as v3 +@TrameApp() class Pan3DView(html.Div): - """ - Self-contained VTK visualization widget with interaction controls. - - Provides: - - VTK render window with local/remote rendering options - - Standard 3D interaction toolbar - - Camera controls and view presets - - Orientation marker widget - - Usage: - view = Pan3DView( - local_rendering="wasm", - on_camera_change=handle_camera_update - ) - view.add_actor(my_vtk_actor) - view.reset_camera() - """ - - _next_id = 0 - def __init__( self, - # Rendering options + render_window, + import_pending="import_pending", + axis_names="axis_names", local_rendering=None, - interactive_ratio=1, - interactive_quality=50, - # State variable names (optional) - view_mode_name=None, - lock_view_name=None, - orientation_widget_name=None, - # Callbacks - on_camera_change=None, - on_view_mode_change=None, - # UI options - show_toolbar=True, - toolbar_position="bottom", - background_color=(0.1, 0.1, 0.1), - # VTK widgets to overlay widgets=None, + disable_style_toggle=False, + disable_roll=False, + disable_axis_align=False, **kwargs, ): - """ - Create a Pan3D visualization widget. - - Parameters - ---------- - local_rendering : str, optional - "wasm" or "vtkjs" for client-side rendering - interactive_ratio : int - Image downsampling ratio during interaction - interactive_quality : int - JPEG quality during interaction (0-100) - view_mode_name : str, optional - State variable name for 2D/3D mode - lock_view_name : str, optional - State variable name for view lock - orientation_widget_name : str, optional - State variable name for orientation widget visibility - on_camera_change : callable, optional - Callback when camera changes - on_view_mode_change : callable, optional - Callback when 2D/3D mode changes - show_toolbar : bool - Whether to show the interaction toolbar - toolbar_position : str - Toolbar position: "top", "bottom", "left", "right" - background_color : tuple - RGB background color (0-1 range) - widgets : list, optional - List of VTK widgets to add to the view - """ - super().__init__(**kwargs) - + super().__init__(classes="pan3d-view", **kwargs) + self.toolbar = None # Activate CSS self.server.enable_module(base) self.server.enable_module(vtk_view) - # Generate unique namespace - Pan3DView._next_id += 1 - self._id = Pan3DView._next_id - ns = f"pan3d_view_{self._id}" - - # Store configuration - self.local_rendering = local_rendering - self.interactive_ratio = interactive_ratio - self.interactive_quality = interactive_quality - self.show_toolbar = show_toolbar - self.toolbar_position = toolbar_position - self._on_camera_change = on_camera_change - self._on_view_mode_change = on_view_mode_change + # Initialize conditional widgets + vtkw.initialize(self.server) + wasm.initialize(self.server) - # Initialize state variables - self.__view_mode = view_mode_name or f"{ns}_view_mode" - self.__lock_view = lock_view_name or f"{ns}_lock_view" - self.__orientation_widget = ( - orientation_widget_name or f"{ns}_orientation_widget" - ) - self.__camera_position = f"{ns}_camera_position" - self.__camera_focal_point = f"{ns}_camera_focal_point" - self.__camera_view_up = f"{ns}_camera_view_up" + self._import_pending = import_pending + self.render_window = render_window + self.renderer = render_window.GetRenderers().GetFirstRenderer() + self.camera = self.renderer.active_camera - # Set default state - self.state[self.__view_mode] = "3D" - self.state[self.__lock_view] = False - self.state[self.__orientation_widget] = True + # Expose view_reset_clipping_range + self.ctrl.view_reset_clipping_range = self.renderer.ResetCameraClippingRange - # Initialize VTK objects - self._renderer = vtkRenderer() - self._renderer.SetBackground(*background_color) + # Reserved state with default + self.state.setdefault("view_3d", True) + self.state.setdefault(import_pending, False) - self._render_window = vtkRenderWindow() - self._render_window.AddRenderer(self._renderer) - self._render_window.OffScreenRenderingOn() - - self._interactor = vtkRenderWindowInteractor() - self._interactor.SetRenderWindow(self._render_window) - self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - - # Set up orientation widget - self._setup_orientation_widget() - - # Add any provided widgets - self._widgets = widgets or [] - for widget in self._widgets: - if hasattr(widget, "SetInteractor"): - widget.SetInteractor(self._interactor) - if hasattr(widget, "EnabledOn"): - widget.EnabledOn() - - # Register state callbacks - self.state.change(self.__view_mode)(self._on_view_mode_change) - - # Build UI directly in __init__ with self: - with html.Div(classes="d-flex flex-column fill-height"): - # Toolbar positioning logic - toolbar_orientation = ( - "vertical" - if self.toolbar_position in ["left", "right"] - else "horizontal" - ) - - # Top toolbar - if self.show_toolbar and self.toolbar_position == "top": - self._toolbar = self._create_toolbar_ui(toolbar_orientation) - - # Main view area - with html.Div(classes="flex-grow-1 d-flex"): - # Left toolbar - if self.show_toolbar and self.toolbar_position == "left": - self._toolbar = self._create_toolbar_ui(toolbar_orientation) - - # VTK render window - if self.local_rendering == "wasm": - # WebAssembly rendering - self._vtk_view = vtk_widgets.VtkLocalView( - render_window=self._render_window, classes="flex-grow-1" - ) - elif self.local_rendering == "vtkjs": - # VTK.js rendering - self._vtk_view = vtk_widgets.VtkLocalView( - render_window=self._render_window, - mode="local", - classes="flex-grow-1", - ) - else: - # Remote rendering - self._vtk_view = vtk_widgets.VtkRemoteView( - render_window=self._render_window, - interactive_ratio=self.interactive_ratio, - interactive_quality=self.interactive_quality, - interactor_events=( - "['LeftButtonPress', 'LeftButtonRelease', 'MouseMove', " - "'RightButtonPress', 'RightButtonRelease', 'MouseWheelForward', " - "'MouseWheelBackward', 'KeyPress']", - ), - classes="flex-grow-1", - StartInteraction=self._start_interaction, - EndInteraction=self._end_interaction, - MouseMove=( - self._on_mouse_move, - "[utils.vtk.event($event)]", + # 3D view + if local_rendering is not None: + if local_rendering == "wasm": + with wasm.LocalView( + self.render_window, + throttle_rate=10, + listeners=("wasm_listeners", {}), + ) as view: + self.ctrl.view_update_force = view.update + self.ctrl.view_update = view.update_throttle + self.ctrl.view_reset_camera = view.reset_camera + camera_id = view.get_wasm_id(self.camera) + self.state.setdefault("wasm_camera", None) + self.state.wasm_listeners = { + camera_id: { + "ModifiedEvent": { + "wasm_camera": { + "position": [camera_id, "Position"], + "view_up": [camera_id, "ViewUp"], + "focal_point": [camera_id, "FocalPoint"], + } + } + } + } + for w in widgets or []: + view.register_vtk_object(w) + else: + with vtkw.VtkLocalView(self.render_window) as view: + self.ctrl.view_update = view.update + self.ctrl.view_reset_camera = view.reset_camera + view.set_widgets(widgets or []) + else: + with vtkw.VtkRemoteView( + self.render_window, interactive_ratio=1 + ) as view: + self.ctrl.view_update = view.update + self.ctrl.view_reset_camera = view.reset_camera + + # Scroll locking overlay + html.Div(v_show=("view_locked", False), classes="view-lock") + + # 3D toolbox + with v3.VCard(classes="view-toolbar pa-1", rounded="lg") as toolbar: + self.toolbar = toolbar + with v3.VTooltip(text="Lock view interaction"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon=( + "view_locked ? 'mdi-lock-outline' : 'mdi-lock-off-outline'", ), + click="view_locked = !view_locked", + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Reset camera"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-crop-free", + click=self.ctrl.view_reset_camera, ) - # Right toolbar - if self.show_toolbar and self.toolbar_position == "right": - self._toolbar = self._create_toolbar_ui(toolbar_orientation) - - # Bottom toolbar - if self.show_toolbar and self.toolbar_position == "bottom": - self._toolbar = self._create_toolbar_ui(toolbar_orientation) - - # ------------------------------------------------------------------------- - # Properties - # ------------------------------------------------------------------------- - - @property - def renderer(self): - """Get the VTK renderer.""" - return self._renderer - - @property - def render_window(self): - """Get the VTK render window.""" - return self._render_window - - @property - def interactor(self): - """Get the VTK interactor.""" - return self._interactor - - @property - def camera(self): - """Get the active camera.""" - return self._renderer.GetActiveCamera() - - @property - def view_mode(self): - """Get/set the view mode (2D/3D).""" - return self.state[self.__view_mode] - - @view_mode.setter - def view_mode(self, value): - with self.state: - self.state[self.__view_mode] = value - - @property - def lock_view(self): - """Get/set the view lock state.""" - return self.state[self.__lock_view] - - @lock_view.setter - def lock_view(self, value): - with self.state: - self.state[self.__lock_view] = bool(value) - - # ------------------------------------------------------------------------- - # UI Building - # ------------------------------------------------------------------------- - - def _create_toolbar_ui(self, orientation): - """Create the interaction toolbar UI.""" - with html.Div(classes=f"pa-1 d-flex align-center {orientation}") as toolbar: - # Lock/unlock view - v3.VBtn( - icon=True, - size="small", - click=(f"{self.__lock_view} = !{self.__lock_view}",), - ).add_child( - v3.VIcon( - f"{{{self.__lock_view} ? 'mdi-lock' : 'mdi-lock-open'}}", - size="small", - ) - ) - - v3.VDivider(vertical=(orientation == "horizontal")) - - # 2D/3D mode - v3.VBtnToggle( - v_model=(self.__view_mode,), - density="compact", - divided=True, - mandatory=True, - ).add_children( - [ - v3.VBtn(value="2D", size="small").add_child("2D"), - v3.VBtn(value="3D", size="small").add_child("3D"), - ] - ) - - v3.VDivider(vertical=(orientation == "horizontal")) - - # Camera presets - v3.VBtn(icon="mdi-home", size="small", click=self.reset_camera) - - v3.VBtn(icon="mdi-rotate-left", size="small", click=self._rotate_left_90) - - v3.VBtn(icon="mdi-rotate-right", size="small", click=self._rotate_right_90) - - v3.VDivider(vertical=(orientation == "horizontal")) - - # Axis alignment - for axis, icon in [ - ("X", "mdi-alpha-x"), - ("Y", "mdi-alpha-y"), - ("Z", "mdi-alpha-z"), - ]: - v3.VBtn( - icon=icon, size="small", click=(self._align_to_axis, f"['{axis}']") - ) - - v3.VDivider(vertical=(orientation == "horizontal")) - - # Orientation widget toggle - v3.VBtn( - icon="mdi-axis-arrow", - size="small", - color=(f"{self.__orientation_widget} ? 'primary' : ''",), - click=(f"{self.__orientation_widget} = !{self.__orientation_widget}",), - ) - - return toolbar - - def _setup_orientation_widget(self): - """Set up the orientation marker widget.""" - axes = vtkAxesActor() - self._orientation_widget = vtk_widgets.vtkOrientationMarkerWidget() - self._orientation_widget.SetOrientationMarker(axes) - self._orientation_widget.SetInteractor(self._interactor) - self._orientation_widget.SetViewport(0.85, 0, 1, 0.15) - self._orientation_widget.EnabledOn() - self._orientation_widget.InteractiveOff() - - # Bind visibility to state - self.state.change(self.__orientation_widget)(self._toggle_orientation_widget) - - # ------------------------------------------------------------------------- - # VTK Operations - # ------------------------------------------------------------------------- - - def add_actor(self, actor): - """Add an actor to the renderer.""" - self._renderer.AddActor(actor) - - def remove_actor(self, actor): - """Remove an actor from the renderer.""" - self._renderer.RemoveActor(actor) - - def clear_actors(self): - """Remove all actors from the renderer.""" - self._renderer.RemoveAllViewProps() - - def reset_camera(self): - """Reset camera to fit all visible objects.""" - self._renderer.ResetCamera() - self.update() - - def update(self): - """Trigger a render update.""" - self._render_window.Render() - if hasattr(self._vtk_view, "update"): - self._vtk_view.update() - self._update_camera_state() - - def set_background(self, r, g, b): - """Set the background color.""" - self._renderer.SetBackground(r, g, b) - self.update() - - # ------------------------------------------------------------------------- - # Event Handlers - # ------------------------------------------------------------------------- - - def _on_view_mode_change(self, mode, **kwargs): - """Handle 2D/3D mode change.""" - if mode == "2D": - self._interactor.GetInteractorStyle().SetCurrentStyleToImage() - else: - self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - - if self._on_view_mode_change: - self._on_view_mode_change(mode) - - self.update() - - def _toggle_orientation_widget(self, visible, **kwargs): - """Toggle orientation widget visibility.""" - if visible: - self._orientation_widget.EnabledOn() + if not (disable_style_toggle and disable_roll and disable_axis_align): + v3.VDivider(classes="my-1") + + if not disable_style_toggle: + with v3.VTooltip(text="Toggle between 3D/2D interaction"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon=( + "view_3d ? 'mdi-rotate-orbit' : 'mdi-cursor-move'", + ), + click="view_3d = !view_3d", + ) + v3.VDivider(classes="my-1") + if not disable_roll: + with v3.VTooltip(text="Rotate left 90"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-rotate-left", + click=(self.rotate_camera, "[-1]"), + ) + with v3.VTooltip(text="Rotate right 90"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-rotate-right", + click=(self.rotate_camera, "[+1]"), + ) + v3.VDivider(classes="my-1") + + if not disable_axis_align: + with v3.VTooltip( + text=(f"`Look toward ${{ {axis_names}[0] || 'X' }}`",) + ): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-x-arrow", + click=(self.reset_camera_to_axis, "[[1,0,0]]"), + ) + with v3.VTooltip( + text=(f"`Look toward ${{ {axis_names}[1] || 'Y'}}`",) + ): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-y-arrow", + click=(self.reset_camera_to_axis, "[[0,1,0]]"), + ) + + with v3.VTooltip( + text=(f"`Look toward ${{ {axis_names}[2] || 'Z'}}`",) + ): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-z-arrow", + click=(self.reset_camera_to_axis, "[[0,0,1]]"), + ) + v3.VDivider(classes="my-1") + with v3.VTooltip(text="Look toward at an angle"): + with html.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + flat=True, + density="compact", + icon="mdi-axis-arrow", + click=(self.reset_camera_to_axis, "[[1,1,1]]"), + ) + + def reset_camera_to_axis(self, axis): + camera = self.renderer.active_camera + camera.focal_point = (0, 0, 0) + camera.position = axis + camera.view_up = VIEW_UPS.get(tuple(axis)) + self.renderer.ResetCamera() + self.ctrl.view_update(push_camera=True) + + def rotate_camera(self, direction): + camera = self.renderer.active_camera + a = [*camera.view_up] + b = [camera.focal_point[i] - camera.position[i] for i in range(3)] + view_up = [ + direction * (a[1] * b[2] - a[2] * b[1]), + direction * (a[2] * b[0] - a[0] * b[2]), + direction * (a[0] * b[1] - a[1] * b[0]), + ] + camera.view_up = view_up + + self.ctrl.view_update(push_camera=True) + + @change("wasm_camera") + def _on_camera(self, wasm_camera, **_): + if wasm_camera is None: + return + + for k, v in wasm_camera.items(): + setattr(self.camera, k, v) + + @change("view_3d") + def _on_view_type_change(self, view_3d, **_): + # FIXME properly swap interactor style + if view_3d: + # self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.renderer.GetActiveCamera().SetParallelProjection(0) else: - self._orientation_widget.EnabledOff() - self.update() - - def _start_interaction(self): - """Handle start of interaction.""" - - def _end_interaction(self): - """Handle end of interaction.""" - self._update_camera_state() - if self._on_camera_change: - self._on_camera_change(self.camera) - - def _on_mouse_move(self, event): - """Handle mouse move events.""" - - def _update_camera_state(self): - """Update camera state variables.""" - camera = self.camera - with self.state: - self.state[self.__camera_position] = list(camera.GetPosition()) - self.state[self.__camera_focal_point] = list(camera.GetFocalPoint()) - self.state[self.__camera_view_up] = list(camera.GetViewUp()) - - def _rotate_left_90(self): - """Rotate view 90 degrees left.""" - self.camera.Roll(-90) - self.update() - - def _rotate_right_90(self): - """Rotate view 90 degrees right.""" - self.camera.Roll(90) - self.update() - - def _align_to_axis(self, axis): - """Align camera to look along specified axis.""" - self._renderer.ResetCamera() - camera = self.camera - focal = camera.GetFocalPoint() - distance = camera.GetDistance() - - if axis == "X": - camera.SetPosition(focal[0] + distance, focal[1], focal[2]) - camera.SetViewUp(0, 0, 1) - elif axis == "Y": - camera.SetPosition(focal[0], focal[1] + distance, focal[2]) - camera.SetViewUp(0, 0, 1) - elif axis == "Z": - camera.SetPosition(focal[0], focal[1], focal[2] + distance) - camera.SetViewUp(0, 1, 0) + # self.interactor.GetInteractorStyle().SetCu() + self.renderer.GetActiveCamera().SetParallelProjection(1) - self.update() + if not self.state[self._import_pending]: + self.ctrl.view_reset_camera() diff --git a/src/pan3d/widgets/save_dataset_dialog.py b/src/pan3d/widgets/save_dataset_dialog.py index 8a950a2a..061891e3 100644 --- a/src/pan3d/widgets/save_dataset_dialog.py +++ b/src/pan3d/widgets/save_dataset_dialog.py @@ -12,7 +12,7 @@ class SaveDatasetDialog(v3.VDialog): def __init__( self, - save_callback, + save_callback=None, v_model=("show_save_dialog", False), save_path_model=("save_path", "output.nc"), title="Save Dataset", @@ -54,7 +54,17 @@ def __init__( ) v3.VBtn( "Save", - click=(save_callback, f"[{save_path_model[0]}]"), + click=(self.save_callback, f"[{save_path_model[0]}]"), variant="elevated", color="primary", ) + + @property + def save_callback(self): + """Get the save callback function.""" + return self._save_callback + + @save_callback.setter + def save_callback(self, value): + """Set the save callback function.""" + self._save_callback = value diff --git a/src/pan3d/widgets/summary_toolbar.py b/src/pan3d/widgets/summary_toolbar.py new file mode 100644 index 00000000..dc89223f --- /dev/null +++ b/src/pan3d/widgets/summary_toolbar.py @@ -0,0 +1,95 @@ +"""Summary toolbar widget for displaying time navigation and color selection.""" + +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class SummaryToolbar(v3.VCard): + """ + A toolbar widget that displays time navigation controls and color selection. + + This widget provides a compact interface for: + - Displaying current time label + - Time slider navigation + - Time index display + - Color by variable selection + """ + + def __init__( + self, + t_labels="t_labels", + slice_t="slice_t", + slice_t_max="slice_t_max", + color_by="color_by", + data_arrays="data_arrays", + max_time_width="max_time_width", + max_time_index_width="max_time_index_width", + **kwargs, + ): + """ + Initialize the SummaryToolbar widget. + + Parameters: + t_labels: State variable name for time labels list + slice_t: State variable name for current time index + slice_t_max: State variable name for maximum time index + color_by: State variable name for color by selection + data_arrays: State variable name for available data arrays + max_time_width: State variable name for max time label width + max_time_index_width: State variable name for max time index width + **kwargs: Additional arguments passed to VCard + """ + super().__init__( + classes="summary-toolbar", + rounded="pill", + **kwargs, + ) + + # Activate CSS - these modules should be imported and enabled at app level + from pan3d.ui.css import base, preview + + self.server.enable_module(base) + self.server.enable_module(preview) + + with self: + with v3.VToolbar( + classes="pl-2", + height=50, + elevation=1, + style="background: none;", + ): + v3.VIcon("mdi-clock-outline") + html.Pre( + f"{{{{ {t_labels}[slice_t] }}}}", + classes="mx-2 text-left", + style=(f"`min-width: ${{ {max_time_width} }}rem;`",), + ) + v3.VSlider( + prepend_inner_icon="mdi-clock-outline", + v_model=(slice_t, 0), + min=0, + max=(slice_t_max, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + classes="mx-2", + ) + html.Div( + f"{{{{ {slice_t} + 1 }}}}/{{{{ {slice_t_max} + 1 }}}}", + classes="mx-2 text-right", + style=(f"`min-width: ${{ {max_time_index_width} }}rem;`",), + ) + v3.VSelect( + placeholder="Color By", + prepend_inner_icon="mdi-format-color-fill", + v_model=(color_by, None), + items=(data_arrays, []), + clearable=True, + hide_details=True, + density="compact", + flat=True, + variant="solo", + max_width=200, + ) diff --git a/src/trame/widgets/pan3d.py b/src/trame/widgets/pan3d.py index 09deb223..f01c2641 100644 --- a/src/trame/widgets/pan3d.py +++ b/src/trame/widgets/pan3d.py @@ -1,4 +1,17 @@ +from pan3d.widgets.clip_slice_control import ClipSliceControl from pan3d.widgets.color_by import ColorBy +from pan3d.widgets.data_information import DataInformation from pan3d.widgets.scalar_bar import ScalarBar +from pan3d.widgets.slice_control import SliceControl +from pan3d.widgets.time_navigation import TimeNavigation +from pan3d.widgets.vector_property_control import VectorPropertyControl -__all__ = ["ColorBy", "ScalarBar"] +__all__ = [ + "ClipSliceControl", + "ColorBy", + "DataInformation", + "ScalarBar", + "SliceControl", + "TimeNavigation", + "VectorPropertyControl", +] From 5a64929bc959ba3aeee6ed1488406a4280427841 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Tue, 19 Aug 2025 09:50:39 -0700 Subject: [PATCH 5/5] refactor: Remove duplicate code file and unneeded decorator --- src/pan3d/custom/contour.py | 2 +- src/pan3d/explorers/analytics.py | 2 +- src/pan3d/explorers/contour.py | 2 +- src/pan3d/explorers/globe.py | 2 +- src/pan3d/explorers/slicer.py | 2 +- src/pan3d/ui/vtk_view.py | 236 ------------------------------- src/pan3d/viewers/preview.py | 2 +- src/pan3d/widgets/pan3d_view.py | 3 +- 8 files changed, 7 insertions(+), 244 deletions(-) delete mode 100644 src/pan3d/ui/vtk_view.py diff --git a/src/pan3d/custom/contour.py b/src/pan3d/custom/contour.py index 8f30c2b2..7cc76bca 100644 --- a/src/pan3d/custom/contour.py +++ b/src/pan3d/custom/contour.py @@ -25,9 +25,9 @@ ) from pan3d.ui.css import base, preview -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.convert import to_float, to_image from pan3d.utils.presets import PRESETS, set_preset +from pan3d.widgets.pan3d_view import Pan3DView from trame.app import get_server from trame.decorators import TrameApp, change from trame.ui.vuetify3 import VAppLayout diff --git a/src/pan3d/explorers/analytics.py b/src/pan3d/explorers/analytics.py index d61a6908..3787aec7 100644 --- a/src/pan3d/explorers/analytics.py +++ b/src/pan3d/explorers/analytics.py @@ -25,9 +25,9 @@ ) from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change from trame.widgets import html diff --git a/src/pan3d/explorers/contour.py b/src/pan3d/explorers/contour.py index 0e0226f0..fd644405 100644 --- a/src/pan3d/explorers/contour.py +++ b/src/pan3d/explorers/contour.py @@ -25,9 +25,9 @@ from pan3d.ui.contour import ContourRenderingSettings from pan3d.ui.layouts import StandardExplorerLayout -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change diff --git a/src/pan3d/explorers/globe.py b/src/pan3d/explorers/globe.py index 27feddaa..8e5d0ba4 100644 --- a/src/pan3d/explorers/globe.py +++ b/src/pan3d/explorers/globe.py @@ -20,9 +20,9 @@ from pan3d.filters.globe import ProjectToSphere from pan3d.ui.globe import GlobeRenderingSettings from pan3d.ui.layouts import StandardExplorerLayout -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.globe import get_continent_outlines, get_globe, get_globe_textures +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change diff --git a/src/pan3d/explorers/slicer.py b/src/pan3d/explorers/slicer.py index be8c0ff9..79b740b3 100644 --- a/src/pan3d/explorers/slicer.py +++ b/src/pan3d/explorers/slicer.py @@ -26,8 +26,8 @@ from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.slicer import SliceRenderingSettings -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change from trame.widgets import html diff --git a/src/pan3d/ui/vtk_view.py b/src/pan3d/ui/vtk_view.py deleted file mode 100644 index b0cbcf70..00000000 --- a/src/pan3d/ui/vtk_view.py +++ /dev/null @@ -1,236 +0,0 @@ -from pan3d.ui.css import base, vtk_view -from pan3d.utils.constants import VIEW_UPS -from trame.decorators import TrameApp, change -from trame.widgets import html -from trame.widgets import vtk as vtkw -from trame.widgets import vtklocal as wasm -from trame.widgets import vuetify3 as v3 - - -@TrameApp() -class Pan3DView(html.Div): - def __init__( - self, - render_window, - import_pending="import_pending", - axis_names="axis_names", - local_rendering=None, - widgets=None, - disable_style_toggle=False, - disable_roll=False, - disable_axis_align=False, - **kwargs, - ): - super().__init__(classes="pan3d-view", **kwargs) - self.toolbar = None - # Activate CSS - self.server.enable_module(base) - self.server.enable_module(vtk_view) - - # Initialize conditional widgets - vtkw.initialize(self.server) - wasm.initialize(self.server) - - self._import_pending = import_pending - self.render_window = render_window - self.renderer = render_window.GetRenderers().GetFirstRenderer() - self.camera = self.renderer.active_camera - - # Expose view_reset_clipping_range - self.ctrl.view_reset_clipping_range = self.renderer.ResetCameraClippingRange - - # Reserved state with default - self.state.setdefault("view_3d", True) - self.state.setdefault(import_pending, False) - - with self: - # 3D view - if local_rendering is not None: - if local_rendering == "wasm": - with wasm.LocalView( - self.render_window, - throttle_rate=10, - listeners=("wasm_listeners", {}), - ) as view: - self.ctrl.view_update_force = view.update - self.ctrl.view_update = view.update_throttle - self.ctrl.view_reset_camera = view.reset_camera - camera_id = view.get_wasm_id(self.camera) - self.state.setdefault("wasm_camera", None) - self.state.wasm_listeners = { - camera_id: { - "ModifiedEvent": { - "wasm_camera": { - "position": [camera_id, "Position"], - "view_up": [camera_id, "ViewUp"], - "focal_point": [camera_id, "FocalPoint"], - } - } - } - } - for w in widgets or []: - view.register_vtk_object(w) - else: - with vtkw.VtkLocalView(self.render_window) as view: - self.ctrl.view_update = view.update - self.ctrl.view_reset_camera = view.reset_camera - view.set_widgets(widgets or []) - else: - with vtkw.VtkRemoteView( - self.render_window, interactive_ratio=1 - ) as view: - self.ctrl.view_update = view.update - self.ctrl.view_reset_camera = view.reset_camera - - # Scroll locking overlay - html.Div(v_show=("view_locked", False), classes="view-lock") - - # 3D toolbox - with v3.VCard(classes="view-toolbar pa-1", rounded="lg") as toolbar: - self.toolbar = toolbar - with v3.VTooltip(text="Lock view interaction"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon=( - "view_locked ? 'mdi-lock-outline' : 'mdi-lock-off-outline'", - ), - click="view_locked = !view_locked", - ) - v3.VDivider(classes="my-1") - with v3.VTooltip(text="Reset camera"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-crop-free", - click=self.ctrl.view_reset_camera, - ) - - if not (disable_style_toggle and disable_roll and disable_axis_align): - v3.VDivider(classes="my-1") - - if not disable_style_toggle: - with v3.VTooltip(text="Toggle between 3D/2D interaction"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon=( - "view_3d ? 'mdi-rotate-orbit' : 'mdi-cursor-move'", - ), - click="view_3d = !view_3d", - ) - v3.VDivider(classes="my-1") - if not disable_roll: - with v3.VTooltip(text="Rotate left 90"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-rotate-left", - click=(self.rotate_camera, "[-1]"), - ) - with v3.VTooltip(text="Rotate right 90"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-rotate-right", - click=(self.rotate_camera, "[+1]"), - ) - v3.VDivider(classes="my-1") - - if not disable_axis_align: - with v3.VTooltip( - text=(f"`Look toward ${{ {axis_names}[0] || 'X' }}`",) - ): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-axis-x-arrow", - click=(self.reset_camera_to_axis, "[[1,0,0]]"), - ) - with v3.VTooltip( - text=(f"`Look toward ${{ {axis_names}[1] || 'Y'}}`",) - ): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-axis-y-arrow", - click=(self.reset_camera_to_axis, "[[0,1,0]]"), - ) - - with v3.VTooltip( - text=(f"`Look toward ${{ {axis_names}[2] || 'Z'}}`",) - ): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-axis-z-arrow", - click=(self.reset_camera_to_axis, "[[0,0,1]]"), - ) - v3.VDivider(classes="my-1") - with v3.VTooltip(text="Look toward at an angle"): - with html.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - flat=True, - density="compact", - icon="mdi-axis-arrow", - click=(self.reset_camera_to_axis, "[[1,1,1]]"), - ) - - def reset_camera_to_axis(self, axis): - camera = self.renderer.active_camera - camera.focal_point = (0, 0, 0) - camera.position = axis - camera.view_up = VIEW_UPS.get(tuple(axis)) - self.renderer.ResetCamera() - self.ctrl.view_update(push_camera=True) - - def rotate_camera(self, direction): - camera = self.renderer.active_camera - a = [*camera.view_up] - b = [camera.focal_point[i] - camera.position[i] for i in range(3)] - view_up = [ - direction * (a[1] * b[2] - a[2] * b[1]), - direction * (a[2] * b[0] - a[0] * b[2]), - direction * (a[0] * b[1] - a[1] * b[0]), - ] - camera.view_up = view_up - - self.ctrl.view_update(push_camera=True) - - @change("wasm_camera") - def _on_camera(self, wasm_camera, **_): - if wasm_camera is None: - return - - for k, v in wasm_camera.items(): - setattr(self.camera, k, v) - - @change("view_3d") - def _on_view_type_change(self, view_3d, **_): - # FIXME properly swap interactor style - if view_3d: - # self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - self.renderer.GetActiveCamera().SetParallelProjection(0) - else: - # self.interactor.GetInteractorStyle().SetCu() - self.renderer.GetActiveCamera().SetParallelProjection(1) - - if not self.state[self._import_pending]: - self.ctrl.view_reset_camera() diff --git a/src/pan3d/viewers/preview.py b/src/pan3d/viewers/preview.py index 9b6461aa..84418bcb 100644 --- a/src/pan3d/viewers/preview.py +++ b/src/pan3d/viewers/preview.py @@ -15,9 +15,9 @@ from pan3d.ui.layouts import StandardExplorerLayout from pan3d.ui.preview import RenderingSettings -from pan3d.ui.vtk_view import Pan3DView from pan3d.utils.common import Explorer from pan3d.utils.convert import to_float +from pan3d.widgets.pan3d_view import Pan3DView from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.decorators import change diff --git a/src/pan3d/widgets/pan3d_view.py b/src/pan3d/widgets/pan3d_view.py index b0cbcf70..28a415c3 100644 --- a/src/pan3d/widgets/pan3d_view.py +++ b/src/pan3d/widgets/pan3d_view.py @@ -1,13 +1,12 @@ from pan3d.ui.css import base, vtk_view from pan3d.utils.constants import VIEW_UPS -from trame.decorators import TrameApp, change +from trame.decorators import change from trame.widgets import html from trame.widgets import vtk as vtkw from trame.widgets import vtklocal as wasm from trame.widgets import vuetify3 as v3 -@TrameApp() class Pan3DView(html.Div): def __init__( self,