From f6dce5ebfb128fbaee5e3dd18de8fb65c167432c Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 7 Aug 2025 21:33:23 +0200 Subject: [PATCH 1/4] initial version of value selecting tool --- src/plopp/widgets/clip3d.py | 155 +++++++++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 13 deletions(-) diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index cb414ab3a..e18105329 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -187,6 +187,103 @@ 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, value: bool): + """ """ + return + + # def move(self, value: dict[str, Any]): + # """ + # """ + # return + # # Early return if relative difference between new and old value is small. + # # This also prevents flickering of an existing cut when a new cut is added. + # if ( + # np.abs(np.array(value['new']) - np.array(value['old'])).max() + # < 0.01 * self.slider.step + # ): + # return + # for outline, val in zip(self.outlines, value['new'], strict=True): + # pos = list(outline.position) + # axis = 'xyz'.index(self._direction) + # pos[axis] = val + # outline.position = pos + # self._throttled_update() + + @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,6 +327,14 @@ def __init__(self, fig: BaseFig): self._original_nodes = list(self._view.graph_nodes.values()) self._nodes = {} + self._value_limits = sc.concat( + [ + min(n().min() for n in self._original_nodes), + max(n().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( @@ -250,9 +355,16 @@ def __init__(self, fig: BaseFig): tooltip='Add Z cut', layout=layout, ) + self.add_v_cut = ipw.Button( + description='V', + icon='plus', + tooltip='Add Value cut', + layout=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, @@ -300,7 +412,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 +435,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 +460,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 +532,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: From 9964e5738a98766e89599eea752bf22ad6a2dc2b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 7 Aug 2025 22:10:00 +0200 Subject: [PATCH 2/4] fix update and better layout --- src/plopp/widgets/clip3d.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index e18105329..81630cf55 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -280,7 +280,7 @@ def range(self): ) @debounce(0.3) - def _throttled_update(self): + def _throttled_update(self, _): self._update() @@ -336,30 +336,30 @@ def __init__(self, fig: BaseFig): ) self.add_cut_label = ipw.Label('Add cut:') - layout = {'width': '45px', 'padding': '0px 0px 0px 0px'} + # layout = {'width': '40px', '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', - layout=layout, + **BUTTON_LAYOUT, ) self.add_x_cut.on_click(lambda _: self._add_cut('x')) self.add_y_cut.on_click(lambda _: self._add_cut('y')) @@ -375,7 +375,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') @@ -395,7 +395,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') From fc6f3ce490d728a4d0be79cde15c0bca219c3021 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 7 Aug 2025 22:42:04 +0200 Subject: [PATCH 3/4] add unit tests --- src/plopp/widgets/clip3d.py | 32 +++-------------- tests/widgets/clip3d_test.py | 66 +++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 81630cf55..0a0201793 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -203,11 +203,7 @@ class ClipValueTool(ipw.HBox): A function to update the scene. """ - def __init__( - self, - limits: sc.Variable, - update: Callable, - ): + def __init__(self, limits: sc.Variable, update: Callable): self._limits = limits self._unit = self._limits.unit self.visible = True @@ -251,28 +247,9 @@ def toggle(self, owner: ipw.Button): owner.tooltip = 'Hide cut' if self.visible else 'Show cut' self._update() - def toggle_border(self, value: bool): - """ """ + def toggle_border(self, _): return - # def move(self, value: dict[str, Any]): - # """ - # """ - # return - # # Early return if relative difference between new and old value is small. - # # This also prevents flickering of an existing cut when a new cut is added. - # if ( - # np.abs(np.array(value['new']) - np.array(value['old'])).max() - # < 0.01 * self.slider.step - # ): - # return - # for outline, val in zip(self.outlines, value['new'], strict=True): - # pos = list(outline.position) - # axis = 'xyz'.index(self._direction) - # pos[axis] = val - # outline.position = pos - # self._throttled_update() - @property def range(self): return sc.scalar(self.slider.value[0], unit=self._unit), sc.scalar( @@ -329,14 +306,13 @@ def __init__(self, fig: BaseFig): self._value_limits = sc.concat( [ - min(n().min() for n in self._original_nodes), - max(n().max() for n in self._original_nodes), + 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': '40px', 'padding': '0px 0px 0px 0px'} self.add_x_cut = ipw.Button( description='X', icon='plus', diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 02f588d45..73a2c6de7 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,48 @@ 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) def test_move_cut(): From e322de88a9b4267964033cb613761dfaf89c2670 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 7 Aug 2025 22:55:24 +0200 Subject: [PATCH 4/4] add test with spatial and value cuts --- tests/widgets/clip3d_test.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 73a2c6de7..5f48bac0b 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -87,6 +87,36 @@ def test_value_cuts(multiple_nodes): 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(): da = scatter() fig = scatter3dfigure(Node(da), x='x', y='y', z='z', cbar=True)