diff --git a/src/pan3d/explorers/analytics.py b/src/pan3d/explorers/analytics.py index 5e23fae..0d72f92 100644 --- a/src/pan3d/explorers/analytics.py +++ b/src/pan3d/explorers/analytics.py @@ -91,29 +91,6 @@ def _setup_vtk(self, pipeline=None): self.widget.EnabledOn() self.widget.InteractiveOff() - # ------------------------------------------------------------------------- - # Trame API - # ------------------------------------------------------------------------- - - def start(self, **kwargs): - """Initialize the UI and start the server for XArray Viewer.""" - self.ui.server.start(**kwargs) - - @property - async def ready(self): - """Start and wait for the XArray Viewer corroutine to be ready.""" - await self.ui.ready - - @property - def state(self): - """Returns the current the trame server state.""" - return self.server.state - - @property - def ctrl(self): - """Returns the Controller for the trame server.""" - return self.server.controller - # ------------------------------------------------------------------------- # UI # ------------------------------------------------------------------------- diff --git a/src/pan3d/ui/contour.py b/src/pan3d/ui/contour.py index f6ccd77..2ba7f29 100644 --- a/src/pan3d/ui/contour.py +++ b/src/pan3d/ui/contour.py @@ -1,7 +1,8 @@ -import math - +from pan3d.ui.shared_components import ( + ScalingControls, + UpdateButton, +) from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -14,62 +15,7 @@ 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", - ) + ScalingControls().create() # contours with v3.VTooltip( @@ -115,36 +61,16 @@ def __init__(self, source, update_rendering, **kwargs): variant="solo", ) v3.VDivider() - v3.VBtn( - "Update 3D view", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("data_arrays.length === 0",), - color=("dirty_data && data_arrays.length ? 'primary': undefined",), - click=(update_rendering, "[true]"), - ) + + UpdateButton(update_rendering).create() def update_from_source(self, source=None): - if source is None: + # Call base implementation + super().update_from_source(source) + + if self.source is None: return + # Additional contour-specific logic with self.state as state: - state.data_arrays_available = source.available_arrays - state.data_arrays = source.arrays state.color_by = 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 - ) diff --git a/src/pan3d/ui/globe.py b/src/pan3d/ui/globe.py index 7ffbede..b3fc7b7 100644 --- a/src/pan3d/ui/globe.py +++ b/src/pan3d/ui/globe.py @@ -1,8 +1,10 @@ -import math - +from pan3d.ui.shared_components import ( + AxisSlicingControls, + SliceSteppingControls, + UpdateButton, +) 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 trame.widgets import vuetify3 as v3 @@ -114,198 +116,16 @@ 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", - ) + # Axis slicing controls + axis_controls = AxisSlicingControls() + for _component in axis_controls.create(): + pass # Component is rendered by being in the context - # 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", - ) - - # 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() - # 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", - ) + # Slice stepping controls + SliceSteppingControls().create() + # Time slider with v3.VTooltip( v_if="slice_t_max > 0", @@ -328,39 +148,30 @@ def __init__(self, source, update_rendering, **kwargs): variant="solo", ) v3.VDivider() - v3.VBtn( - "Update 3D view", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("data_arrays.length === 0",), - color=("dirty_data && data_arrays.length ? 'primary': undefined",), - click=(update_rendering, "[true]"), - ) + + UpdateButton(update_rendering).create() def update_from_source(self, source=None): - if source is None: + # Call base implementation + super().update_from_source(source) + + if self.source is None: return + # Additional globe-specific logic with self.state as state: - state.data_arrays_available = source.available_arrays - state.data_arrays = source.arrays state.color_by = None - state.axis_names = [source.x, source.y, source.z] - state.slice_extents = source.slice_extents - slices = source.slices + slices = self.source.slices for axis in XYZ: # default - axis_extent = state.slice_extents.get(getattr(source, axis)) + axis_extent = state.slice_extents.get(getattr(self.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)) + axis_slice = slices.get(getattr(self.source, axis)) if axis_slice is not None: if isinstance(axis_slice, int): # cut @@ -373,13 +184,3 @@ def update_from_source(self, source=None): axis_slice[1] - 1, ] # 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 - ) diff --git a/src/pan3d/ui/preview.py b/src/pan3d/ui/preview.py index 651a05e..2a4d2bb 100644 --- a/src/pan3d/ui/preview.py +++ b/src/pan3d/ui/preview.py @@ -1,8 +1,11 @@ -import math - +from pan3d.ui.shared_components import ( + AxisSlicingControls, + ScalingControls, + SliceSteppingControls, + UpdateButton, +) 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 trame.widgets import vuetify3 as v3 @@ -21,256 +24,19 @@ 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", - ) - # 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", - ) + # Axis slicing controls + axis_controls = AxisSlicingControls() + for _component in axis_controls.create(): + pass # Component is rendered by being in the context - # 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() + 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", - ) + # Slice stepping controls + SliceSteppingControls().create() # 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", - ) + ScalingControls().create() # Time slider with v3.VTooltip( @@ -294,38 +60,29 @@ def __init__(self, source, update_rendering, **kwargs): variant="solo", ) v3.VDivider() - v3.VBtn( - "Update 3D view", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("data_arrays.length === 0",), - color=("dirty_data && data_arrays.length ? 'primary': undefined",), - click=(update_rendering, "[true]"), - ) + + UpdateButton(update_rendering).create() def update_from_source(self, source=None): - self.source = source or self.source + # Call base implementation + super().update_from_source(source or self.source) + if self.source is None: + return + + # Additional preview-specific logic with self.state as state: - state.data_arrays_available = source.available_arrays - state.data_arrays = source.arrays - # state.color_by = None - state.axis_names = [source.x, source.y, source.z] - state.slice_extents = source.slice_extents - slices = source.slices + slices = self.source.slices for axis in XYZ: # default - axis_extent = state.slice_extents.get(getattr(source, axis)) + axis_extent = state.slice_extents.get(getattr(self.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)) + axis_slice = slices.get(getattr(self.source, axis)) if axis_slice is not None: if isinstance(axis_slice, int): # cut @@ -338,13 +95,3 @@ def update_from_source(self, source=None): axis_slice[1] - 1, ] # 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 - ) diff --git a/src/pan3d/ui/shared_components.py b/src/pan3d/ui/shared_components.py new file mode 100644 index 0000000..69fc375 --- /dev/null +++ b/src/pan3d/ui/shared_components.py @@ -0,0 +1,275 @@ +"""Shared UI components for Pan3D explorers to reduce code duplication.""" + +from pan3d.utils.constants import XYZ +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + + +class TimeSlider: + """Shared time slider component used across multiple explorers.""" + + def __init__( + self, + slice_t="slice_t", + slice_t_max="slice_t_max", + t_labels="t_labels", + max_time_width="max_time_width", + max_time_index_width="max_time_index_width", + ): + self.slice_t = slice_t + self.slice_t_max = slice_t_max + self.t_labels = t_labels + self.max_time_width = max_time_width + self.max_time_index_width = max_time_index_width + + def create(self): + """Create and return the time slider component.""" + with v3.VTooltip(text="Time") as tooltip: + with html.Template(v_slot_activator="{ props }"): + with html.Div( + v_bind="props", + classes="d-flex flex-row align-center px-2", + ): + v3.VIcon("mdi-clock-outline", classes="mr-2") + html.Pre( + f"{{{{ {self.t_labels}[{self.slice_t}] }}}}", + classes="mr-2", + style=(f"`min-width: ${{{self.max_time_width}}}rem;`",), + ) + v3.VSlider( + v_model=(self.slice_t, 0), + min=0, + max=(self.slice_t_max, 0), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + classes="mx-2", + ) + html.Div( + f"{{{{ {self.slice_t} + 1 }}}}/{{{{ {self.slice_t_max} + 1 }}}}", + classes="mx-2 text-right", + style=(f"`min-width: ${{{self.max_time_index_width}}}rem;`",), + ) + return tooltip + + +class ScalingControls: + """Shared scaling controls for X, Y, Z axes in row/column format.""" + + def __init__( + self, + axis_names="axis_names", + scale_x="scale_x", + scale_y="scale_y", + scale_z="scale_z", + ): + self.axis_names = axis_names + self.scale_x = scale_x + self.scale_y = scale_y + self.scale_z = scale_z + + def create(self): + """Create and return the scaling controls component.""" + with v3.VTooltip(text="Representation scaling") as tooltip: + 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", + ) + for i, (_, scale_var) in enumerate( + [ + ("x", self.scale_x), + ("y", self.scale_y), + ("z", self.scale_z), + ] + ): + with v3.VCol(classes="pa-0", v_if=f"{self.axis_names}?.[{i}]"): + v3.VTextField( + v_model=(scale_var, 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", + ) + return tooltip + + +class UpdateButton: + """Shared update button for 3D view.""" + + def __init__( + self, + update_rendering, + data_arrays="data_arrays", + dirty_data="dirty_data", + ): + self.update_rendering = update_rendering + self.data_arrays = data_arrays + self.dirty_data = dirty_data + + def create(self): + """Create and return the update button component.""" + return v3.VBtn( + "Update 3D view", + block=True, + classes="text-none", + flat=True, + density="compact", + rounded=0, + disabled=(f"!{self.data_arrays}.length",), + color=(f"{self.dirty_data} ? 'orange-darken-2': 'primary'",), + click=(self.update_rendering, "[true]"), + ) + + +class AxisSlicingControl: + """Shared axis slicing control for a single axis.""" + + def __init__( + self, + axis, + axis_index, + axis_names="axis_names", + slice_extents="slice_extents", + dataset_bounds="dataset_bounds", + ): + self.axis = axis + self.axis_index = axis_index + self.axis_names = axis_names + self.slice_extents = slice_extents + self.dataset_bounds = dataset_bounds + self.axis_lower = axis.lower() + + def create(self): + """Create and return the axis slicing control component.""" + axis_name = f"{self.axis_names}[{self.axis_index}]" + slice_type = f"slice_{self.axis_lower}_type" + slice_range = f"slice_{self.axis_lower}_range" + slice_cut = f"slice_{self.axis_lower}_cut" + slice_step = f"slice_{self.axis_lower}_step" + bounds_start = self.axis_index * 2 + bounds_end = bounds_start + 1 + + # Different tooltip text formats for preview vs globe styles + with v3.VTooltip( + v_if=f"{self.axis_names}?.[{self.axis_index}]", + text=( + f"`${{{axis_name}}}: [${{{self.dataset_bounds}[{bounds_start}]}}, " + f"${{{self.dataset_bounds}[{bounds_end}]}}] " + f"${{{slice_type} ==='range' ? ('(' + {slice_range}.map((v,i) => v+1).concat({slice_step}).join(', ') + ')'): {slice_cut}}}`", + ), + ) as tooltip: + with html.Template(v_slot_activator="{ props }"): + with html.Div( + classes="d-flex", + v_if=f"{self.axis_names}?.[{self.axis_index}]", + v_bind="props", + ): + v3.VRangeSlider( + v_if=f"{slice_type} === 'range'", + prepend_icon=f"mdi-axis-{self.axis_lower}-arrow", + v_model=(slice_range, None), + min=(f"{self.slice_extents}[{axis_name}][0]",), + max=(f"{self.slice_extents}[{axis_name}][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VSlider( + v_else=True, + prepend_icon=f"mdi-axis-{self.axis_lower}-arrow", + v_model=(slice_cut, 0), + min=(f"{self.slice_extents}[{axis_name}][0]",), + max=(f"{self.slice_extents}[{axis_name}][1]",), + step=1, + hide_details=True, + density="compact", + flat=True, + variant="solo", + ) + v3.VCheckbox( + v_model=(slice_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", + ) + return tooltip + + +class AxisSlicingControls: + """Container for all three axis slicing controls.""" + + def __init__( + self, + axis_names="axis_names", + slice_extents="slice_extents", + dataset_bounds="dataset_bounds", + ): + self.controls = [ + AxisSlicingControl(axis, i, axis_names, slice_extents, dataset_bounds) + for i, axis in enumerate(XYZ) + ] + + def create(self): + """Create and return all axis slicing controls.""" + components = [] + for i, control in enumerate(self.controls): + components.append(control.create()) + if i < len(self.controls) - 1: + components.append(v3.VDivider()) + return components + + +class SliceSteppingControls: + """Shared slice stepping controls for Level of Details.""" + + def __init__(self, axis_names="axis_names"): + self.axis_names = axis_names + + def create(self): + """Create and return the slice stepping controls.""" + with v3.VTooltip(text="Level Of Details / Slice stepping") as tooltip: + 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", + ) + for i, axis in enumerate(XYZ): + with v3.VCol(classes="pa-0", v_if=f"{self.axis_names}?.[{i}]"): + v3.VTextField( + v_model_number=(f"slice_{axis.lower()}_step", 1), + hide_details=True, + density="compact", + flat=True, + variant="solo", + reverse=True, + raw_attrs=['min="1"'], + type="number", + ) + return tooltip diff --git a/src/pan3d/ui/slicer.py b/src/pan3d/ui/slicer.py index ac0dfe9..024aaf0 100644 --- a/src/pan3d/ui/slicer.py +++ b/src/pan3d/ui/slicer.py @@ -1,7 +1,8 @@ -import math - +from pan3d.ui.shared_components import ( + ScalingControls, + UpdateButton, +) from pan3d.utils.common import RenderingSettingsBasic -from pan3d.utils.convert import max_str_length from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -77,62 +78,8 @@ def __init__(self, source, update_rendering, **kwargs): 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", - ) + ScalingControls().create() + v3.VDivider() # Time slider with v3.VTooltip( @@ -156,54 +103,39 @@ def __init__(self, source, update_rendering, **kwargs): variant="solo", ) v3.VDivider() - v3.VBtn( - "Update 3D view", - block=True, - classes="text-none", - flat=True, - density="compact", - rounded=0, - disabled=("data_arrays.length === 0",), - color=("dirty_data && data_arrays.length ? 'primary': undefined",), - click=(update_rendering, "[true]"), - ) + + UpdateButton(update_rendering).create() def update_from_source(self, source=None): - if source is None: + # Call base implementation + super().update_from_source(source) + + if self.source is None: return - ds = source() + # Additional slicer-specific logic + ds = self.source() bounds = ds.bounds origin = [ 0.5 * (bounds[0] + bounds[1]), 0.5 * (bounds[2] + bounds[3]), 0.5 * (bounds[4] + bounds[5]), ] - with self.state as state: - state.data_arrays_available = source.available_arrays - state.data_arrays = source.arrays - state.color_by = None + with self.state as state: + # Override axis_names to filter out None values state.axis_names = [ - x for x in [source.x, source.y, source.z] if x is not None + x + for x in [self.source.x, self.source.y, self.source.z] + if x is not 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 state from dataset state.bounds = ds.bounds state.cut_x = origin[0] state.cut_y = origin[1] state.cut_z = origin[2] - state.slice_axis = source.z if source.z is not None else source.y + state.slice_axis = ( + self.source.z if self.source.z is not None else self.source.y + ) state.slice_axes = state.axis_names diff --git a/src/pan3d/utils/common.py b/src/pan3d/utils/common.py index f7c956e..c6ea030 100644 --- a/src/pan3d/utils/common.py +++ b/src/pan3d/utils/common.py @@ -1,4 +1,5 @@ import json +import math import traceback from pathlib import Path @@ -8,7 +9,7 @@ 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.utils.convert import max_str_length, update_camera from pan3d.widgets.color_by import ColorBy from pan3d.xarray.algorithm import vtkXArrayRectilinearSource from trame.app import TrameApp, asynchronous @@ -844,9 +845,41 @@ def _on_array_selection(self, data_arrays, **_): 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. - """ - ) + """Update the UI state from the given data source. + + This base implementation handles common updates across all rendering settings. + Subclasses should call super().update_from_source(source) and then add their + specific customizations. + + Parameters: + source: The data source to update from (e.g., vtkXArrayRectilinearSource) + """ + # Handle source update + if source is not None: + self.source = source + + if self.source is None: + return + + # Common state updates + with self.state as state: + # Data arrays + state.data_arrays_available = self.source.available_arrays + state.data_arrays = self.source.arrays + + # Axis names - basic version + state.axis_names = [self.source.x, self.source.y, self.source.z] + + # Slice extents + state.slice_extents = self.source.slice_extents + + # Time-related updates + state.slice_t = self.source.t_index + state.slice_t_max = self.source.t_size - 1 + state.t_labels = self.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 + )