diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index cb414ab3..0a020179 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -187,6 +187,80 @@ def _throttled_update(self): self._update() +class ClipValueTool(ipw.HBox): + """ + A tool that provides a slider to extract a points in a three-dimensional + scatter plot based on a value selection criterion, and add it to the scene as an + opaque cut. The slider controls the range of the selection. + + .. versionadded:: 25.08.0 + + Parameters + ---------- + limits: + The spatial extent of the points in the 3d figure in the XYZ directions. + update: + A function to update the scene. + """ + + def __init__(self, limits: sc.Variable, update: Callable): + self._limits = limits + self._unit = self._limits.unit + self.visible = True + self._update = update + self._direction = 'v' + + center = self._limits.mean().value + vmin = self._limits[0].value + vmax = self._limits[1].value + dx = vmax - vmin + delta = 0.05 * dx + self.slider = ipw.FloatRangeSlider( + min=vmin, + max=vmax, + value=[center - delta, center + delta], + step=dx * 0.01, + description="Values", + style={'description_width': 'initial'}, + layout={'width': '470px', 'padding': '0px'}, + ) + + self.cut_visible = ipw.Button( + icon='eye-slash', + tooltip='Hide cut', + layout={'width': '16px', 'padding': '0px'}, + ) + + self.unit_label = ipw.Label(f'[{self._unit}]') + self.cut_visible.on_click(self.toggle) + self.slider.observe(self._throttled_update, names='value') + + super().__init__([self.slider, ipw.Label(f'[{self._unit}]'), self.cut_visible]) + + def toggle(self, owner: ipw.Button): + """ + Toggle the visibility of the cut on and off. + """ + self.visible = not self.visible + self.slider.disabled = not self.visible + owner.icon = 'eye-slash' if self.visible else 'eye' + owner.tooltip = 'Hide cut' if self.visible else 'Show cut' + self._update() + + def toggle_border(self, _): + return + + @property + def range(self): + return sc.scalar(self.slider.value[0], unit=self._unit), sc.scalar( + self.slider.value[1], unit=self._unit + ) + + @debounce(0.3) + def _throttled_update(self, _): + self._update() + + class ClippingPlanes(ipw.HBox): """ A widget to make clipping planes for spatial cutting (see :class:`Clip3dTool`) to @@ -230,29 +304,43 @@ def __init__(self, fig: BaseFig): self._original_nodes = list(self._view.graph_nodes.values()) self._nodes = {} + self._value_limits = sc.concat( + [ + min(n().data.min() for n in self._original_nodes), + max(n().data.max() for n in self._original_nodes), + ], + dim="dummy", + ) + self.add_cut_label = ipw.Label('Add cut:') - layout = {'width': '45px', 'padding': '0px 0px 0px 0px'} self.add_x_cut = ipw.Button( description='X', icon='plus', tooltip='Add X cut', - layout=layout, + **BUTTON_LAYOUT, ) self.add_y_cut = ipw.Button( description='Y', icon='plus', tooltip='Add Y cut', - layout=layout, + **BUTTON_LAYOUT, ) self.add_z_cut = ipw.Button( description='Z', icon='plus', tooltip='Add Z cut', - layout=layout, + **BUTTON_LAYOUT, + ) + self.add_v_cut = ipw.Button( + description='V', + icon='plus', + tooltip='Add Value cut', + **BUTTON_LAYOUT, ) self.add_x_cut.on_click(lambda _: self._add_cut('x')) self.add_y_cut.on_click(lambda _: self._add_cut('y')) self.add_z_cut.on_click(lambda _: self._add_cut('z')) + self.add_v_cut.on_click(lambda _: self._add_cut('v')) self.opacity = ipw.BoundedFloatText( min=0, @@ -263,7 +351,7 @@ def __init__(self, fig: BaseFig): description='Opacity:', tooltip='Set the opacity of the background', style={'description_width': 'initial'}, - layout={'width': '142px', 'padding': '0px 0px 0px 0px'}, + layout={'width': '160px', 'padding': '0px 0px 0px 0px'}, ) self.opacity.observe(self._set_opacity, names='value') @@ -283,7 +371,7 @@ def __init__(self, fig: BaseFig): value='OR', disabled=True, tooltip='Operation to combine multiple cuts', - layout={'width': '60px', 'padding': '0px 0px 0px 0px'}, + layout={'width': '78px', 'padding': '0px 0px 0px 0px'}, ) self.cut_operation.observe(self.change_operation, names='value') @@ -300,7 +388,14 @@ def __init__(self, fig: BaseFig): self.tabs, ipw.VBox( [ - ipw.HBox([self.add_x_cut, self.add_y_cut, self.add_z_cut]), + ipw.HBox( + [ + self.add_x_cut, + self.add_y_cut, + self.add_z_cut, + self.add_v_cut, + ] + ), self.opacity, ipw.HBox( [ @@ -316,17 +411,23 @@ def __init__(self, fig: BaseFig): self.layout.display = 'none' - def _add_cut(self, direction: Literal['x', 'y', 'z']): + def _add_cut(self, direction: Literal['x', 'y', 'z', 'v']): """ Add a cut in the specified direction. """ - cut = Clip3dTool( - direction=direction, - limits=self._limits, - update=self.update_state, - border_visible=self.cut_borders_visibility.value, - ) - self._view.canvas.add(cut.outlines) + if direction == 'v': + cut = ClipValueTool( + limits=self._value_limits, + update=self.update_state, + ) + else: + cut = Clip3dTool( + direction=direction, + limits=self._limits, + update=self.update_state, + border_visible=self.cut_borders_visibility.value, + ) + self._view.canvas.add(cut.outlines) self.cuts.append(cut) self.tabs.children = [*self.tabs.children, cut] self.tabs.selected_index = len(self.cuts) - 1 @@ -335,7 +436,8 @@ def _add_cut(self, direction: Literal['x', 'y', 'z']): def _remove_cut(self, _): cut = self.cuts.pop(self.tabs.selected_index) - self._view.canvas.remove(cut.outlines) + if cut._direction != 'v': + self._view.canvas.remove(cut.outlines) self.tabs.children = self.cuts self.update_state() self.update_controls() @@ -406,9 +508,12 @@ def update_state(self): selections = [] for cut in visible_cuts: xmin, xmax = cut.range - selections.append( - (da.coords[cut.dim] >= xmin) & (da.coords[cut.dim] < xmax) - ) + if cut._direction == 'v': + selections.append((da.data >= xmin) & (da.data < xmax)) + else: + selections.append( + (da.coords[cut.dim] >= xmin) & (da.coords[cut.dim] < xmax) + ) selection = OPERATIONS[self._operation](selections) if selection.sum().value > 0: if n.id not in self._nodes: diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 02f588d4..5f48bac0 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) - +import pytest import scipp as sc from plopp import Node @@ -9,20 +9,27 @@ from plopp.widgets import ClippingPlanes -def test_add_remove_cuts(): - da = scatter() - fig = scatter3dfigure(Node(da), x='x', y='y', z='z', cbar=True) +@pytest.mark.parametrize('multiple_nodes', [False, True]) +def test_add_remove_cuts(multiple_nodes): + a = scatter() + nodes = [Node(a)] + if multiple_nodes: + b = a.copy() + b.coords['x'] += sc.scalar(60, unit='m') + nodes.append(Node(b)) + + fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=True) clip = ClippingPlanes(fig) - assert len(fig.artists) == 1 + assert len(fig.artists) == 1 * len(nodes) clip.add_x_cut.click() - assert len(fig.artists) == 2 + assert len(fig.artists) == 2 * len(nodes) npoints_in_cutx = list(fig.artists.values())[-1]._data.shape[0] clip.add_y_cut.click() - assert len(fig.artists) == 2 + assert len(fig.artists) == 2 * len(nodes) npoints_in_cutxy = list(fig.artists.values())[-1]._data.shape[0] assert npoints_in_cutxy > npoints_in_cutx clip.add_z_cut.click() - assert len(fig.artists) == 2 + assert len(fig.artists) == 2 * len(nodes) npoints_in_cutxyz = list(fig.artists.values())[-1]._data.shape[0] assert npoints_in_cutxyz > npoints_in_cutxy clip.delete_cut.click() @@ -36,7 +43,78 @@ def test_add_remove_cuts(): clip.tabs.selected_index = 0 assert list(fig.artists.values())[-1]._data.shape[0] == npoints_in_cutx clip.delete_cut.click() - assert len(fig.artists) == 1 + assert len(fig.artists) == 1 * len(nodes) + + +@pytest.mark.parametrize('multiple_nodes', [False, True]) +def test_value_cuts(multiple_nodes): + a = scatter() + nodes = [Node(a)] + if multiple_nodes: + b = a.copy() + b.coords['x'] += sc.scalar(60, unit='m') + nodes.append(Node(b)) + fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=True) + clip = ClippingPlanes(fig) + clip.add_v_cut.click() + vcut = clip.cuts[-1] + npoints = list(fig.artists.values())[-1]._data.shape[0] + vcut.slider.value = [vcut.slider.min, vcut.slider.value[1]] + clip.update_state() # Need to manually update state due to debounce mechanism + # We should now have more points in the cut than before because the range is wider + npoints2 = list(fig.artists.values())[-1]._data.shape[0] + assert npoints2 > npoints + + clip.cut_operation.value = 'OR' + # Add a second value cut + clip.add_v_cut.click() + vcut2 = clip.cuts[-1] + vcut2.slider.value = [ + 0.5 * (vcut2.slider.value[1] + vcut2.slider.max), + vcut2.slider.max, + ] + clip.update_state() # Need to manually update state due to debounce mechanism + # We should now have more points in the cut than before because the range is wider + npoints3 = list(fig.artists.values())[-1]._data.shape[0] + assert npoints3 > npoints2 + + clip.delete_cut.click() + assert list(fig.artists.values())[-1]._data.shape[0] == npoints2 + # If the tool is not displayed, the tab selected index does not update when a cut + # is deleted, so we need to manually set it to the correct value + clip.tabs.selected_index = 0 + clip.delete_cut.click() + assert len(fig.artists) == 1 * len(nodes) + + +@pytest.mark.parametrize('multiple_nodes', [False, True]) +def test_mixing_spatial_and_value_cuts(multiple_nodes): + a = scatter() + nodes = [Node(a)] + if multiple_nodes: + b = a.copy() + b.coords['x'] += sc.scalar(60, unit='m') + nodes.append(Node(b)) + fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=True) + clip = ClippingPlanes(fig) + + # Add a spatial cut + clip.add_y_cut.click() + npoints = list(fig.artists.values())[-1]._data.shape[0] + clip.cut_operation.value = 'AND' + + # Add a value cut + clip.add_v_cut.click() + # Adding value selection should limit the number of points further + assert list(fig.artists.values())[-1]._data.shape[0] < npoints + + clip.delete_cut.click() + assert list(fig.artists.values())[-1]._data.shape[0] == npoints + # If the tool is not displayed, the tab selected index does not update when a cut + # is deleted, so we need to manually set it to the correct value + clip.tabs.selected_index = 0 + clip.delete_cut.click() + assert len(fig.artists) == 1 * len(nodes) def test_move_cut():