From f6dce5ebfb128fbaee5e3dd18de8fb65c167432c Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 7 Aug 2025 21:33:23 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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) From 0e365d46a472fa955b2eefe5666cca8f59eb6618 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 13:26:45 +0100 Subject: [PATCH 05/10] fix docstrings and change name to ClippingManager --- src/plopp/plotting/scatter3d.py | 8 ++++---- src/plopp/widgets/clip3d.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plopp/plotting/scatter3d.py b/src/plopp/plotting/scatter3d.py index 207131a9a..e869b1058 100644 --- a/src/plopp/plotting/scatter3d.py +++ b/src/plopp/plotting/scatter3d.py @@ -126,7 +126,7 @@ def scatter3d( A three-dimensional interactive scatter plot. """ from ..graphics import scatter3dfigure - from ..widgets import ClippingPlanes, ToggleTool + from ..widgets import ClippingManager, ToggleTool if 'ax' in kwargs: raise ValueError( @@ -160,11 +160,11 @@ def scatter3d( vmin=vmin, **kwargs, ) - clip_planes = ClippingPlanes(fig) + clip_manager = ClippingManager(fig) fig.toolbar['cut3d'] = ToggleTool( - callback=clip_planes.toggle_visibility, + callback=clip_manager.toggle_visibility, icon='layer-group', tooltip='Hide/show spatial cutting tool', ) - fig.bottom_bar.add(clip_planes) + fig.bottom_bar.add(clip_manager) return fig diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 0a0201793..fb258d6d0 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -190,10 +190,10 @@ def _throttled_update(self): 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 + scatter plot based on a value selection criterion, and adds it to the scene as an opaque cut. The slider controls the range of the selection. - .. versionadded:: 25.08.0 + .. versionadded:: 26.2.0 Parameters ---------- @@ -261,10 +261,11 @@ def _throttled_update(self, _): self._update() -class ClippingPlanes(ipw.HBox): +class ClippingManager(ipw.HBox): """ A widget to make clipping planes for spatial cutting (see :class:`Clip3dTool`) to make spatial cuts in the X, Y, and Z directions on a three-dimensional scatter plot. + The widget also allows to make value-based cuts using :class:`ClipValueTool`. .. versionadded:: 24.04.0 From ba8ed03c63700d2fe9a3f3aa6cb7186161996f79 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 13:56:27 +0100 Subject: [PATCH 06/10] change pixels to em sizes --- src/plopp/widgets/__init__.pyi | 4 ++-- src/plopp/widgets/clip3d.py | 14 +++++++------- src/plopp/widgets/linesave.py | 2 +- src/plopp/widgets/slice.py | 4 ++-- src/plopp/widgets/style.py | 2 +- src/plopp/widgets/tools.py | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index 225f8a653..14a91f325 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -3,7 +3,7 @@ from .box import Box, HBar, VBar from .checkboxes import Checkboxes -from .clip3d import Clip3dTool, ClippingPlanes +from .clip3d import Clip3dTool, ClippingManager from .drawing import DrawingTool, PointsTool from .linesave import LineSaveTool from .slice import RangeSliceWidget, SliceWidget, slice_dims @@ -15,7 +15,7 @@ __all__ = [ "ButtonTool", "Checkboxes", "Clip3dTool", - "ClippingPlanes", + "ClippingManager", "ColorTool", "DrawingTool", "HBar", diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index fb258d6d0..9ebb975a8 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -114,13 +114,13 @@ def __init__( step=dx * 0.01, description=direction.upper(), style={'description_width': 'initial'}, - layout={'width': '470px', 'padding': '0px'}, + layout={'width': '35.6em', 'padding': '0px'}, ) self.cut_visible = ipw.Button( icon='eye-slash', tooltip='Hide cut', - layout={'width': '16px', 'padding': '0px'}, + layout={'width': '1.23em', 'padding': '0px'}, ) for outline, val in zip(self.outlines, self.slider.value, strict=True): @@ -222,13 +222,13 @@ def __init__(self, limits: sc.Variable, update: Callable): step=dx * 0.01, description="Values", style={'description_width': 'initial'}, - layout={'width': '470px', 'padding': '0px'}, + layout={'width': '35.6em', 'padding': '0px'}, ) self.cut_visible = ipw.Button( icon='eye-slash', tooltip='Hide cut', - layout={'width': '16px', 'padding': '0px'}, + layout={'width': '1.23em', 'padding': '0px'}, ) self.unit_label = ipw.Label(f'[{self._unit}]') @@ -301,7 +301,7 @@ def __init__(self, fig: BaseFig): self.cuts = [] self._operation = 'or' - self.tabs = ipw.Tab(layout={'width': '550px'}) + self.tabs = ipw.Tab(layout={'width': '41.6em'}) self._original_nodes = list(self._view.graph_nodes.values()) self._nodes = {} @@ -352,7 +352,7 @@ def __init__(self, fig: BaseFig): description='Opacity:', tooltip='Set the opacity of the background', style={'description_width': 'initial'}, - layout={'width': '160px', 'padding': '0px 0px 0px 0px'}, + layout={'width': '12.1em', 'padding': '0px 0px 0px 0px'}, ) self.opacity.observe(self._set_opacity, names='value') @@ -372,7 +372,7 @@ def __init__(self, fig: BaseFig): value='OR', disabled=True, tooltip='Operation to combine multiple cuts', - layout={'width': '78px', 'padding': '0px 0px 0px 0px'}, + layout={'width': '5.9em', 'padding': '0px 0px 0px 0px'}, ) self.cut_operation.observe(self.change_operation, names='value') diff --git a/src/plopp/widgets/linesave.py b/src/plopp/widgets/linesave.py index 1987c261b..87d76b233 100644 --- a/src/plopp/widgets/linesave.py +++ b/src/plopp/widgets/linesave.py @@ -35,7 +35,7 @@ def __init__(self, data_node: Node, slider_node: Node, fig: View): self.button = ipw.Button(description='Save line') self.button.on_click(self.save_line) self.container = VBar() - super().__init__([self.button, self.container], layout={'width': '350px'}) + super().__init__([self.button, self.container], layout={'width': '26.5em'}) def _update_container(self): self.container.children = [line['tool'] for line in self._lines.values()] diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index cbfac7ee4..6f85cd626 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -32,7 +32,7 @@ def __init__( 'value': (size - 1) // 2 if slider_constr is ipw.IntSlider else None, 'continuous_update': True, 'readout': False, - 'layout': {"width": "200px", "margin": "0px 0px 0px 10px"}, + 'layout': {"width": "15.2em", "margin": "0px 0px 0px 10px"}, } self.dim_label = ipw.Label(value=dim) self.slider = slider_constr(**widget_args) @@ -40,7 +40,7 @@ def __init__( value=True, tooltip="Continuous update", indent=False, - layout={"width": "20px"}, + layout={"width": "1.52em"}, ) self.label = ipw.Label( value=coord_element_to_string(coord[dim, self.slider.value]) diff --git a/src/plopp/widgets/style.py b/src/plopp/widgets/style.py index 3c1a5068d..4e9ab3389 100644 --- a/src/plopp/widgets/style.py +++ b/src/plopp/widgets/style.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) -BUTTON_LAYOUT = {"layout": {"width": "37px", "padding": "0px 0px 0px 0px"}} +BUTTON_LAYOUT = {"layout": {"width": "2.8em", "padding": "0px 0px 0px 0px"}} diff --git a/src/plopp/widgets/tools.py b/src/plopp/widgets/tools.py index 0aec24f40..8c9a48eec 100644 --- a/src/plopp/widgets/tools.py +++ b/src/plopp/widgets/tools.py @@ -54,7 +54,7 @@ def __call__(self, *ignored): class PlusMinusTool(ipw.HBox): def __init__(self, plus, minus): - layout = {'width': '16px', 'padding': '0px'} + layout = {'width': '1.23em', 'padding': '0px'} self.callback = {'plus': plus.pop('callback'), 'minus': minus.pop('callback')} self._plus = ipw.Button(icon='plus', **{**{'layout': layout}, **plus}) self._minus = ipw.Button(icon='minus', **{**{'layout': layout}, **minus}) @@ -230,10 +230,10 @@ class ColorTool(ipw.HBox): """ def __init__(self, text: str, color: str): - layout = ipw.Layout(display="flex", justify_content="flex-end", width='150px') + layout = ipw.Layout(display="flex", justify_content="flex-end", width='11.4em') self.text = ipw.Label(value=text, layout=layout) self.color = ipw.ColorPicker( - concise=True, value=color, description='', layout={'width': "30px"} + concise=True, value=color, description='', layout={'width': "2.3em"} ) self.button = ipw.Button(icon='times', **BUTTON_LAYOUT) super().__init__([self.text, self.color, self.button]) From e55d32e43d47a04e98978f3ab30f783cf7804dd0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 14:31:52 +0100 Subject: [PATCH 07/10] handle selection inside clip tool and make direction member public --- src/plopp/widgets/clip3d.py | 69 ++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 9ebb975a8..844c51443 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -66,16 +66,16 @@ def __init__( border_visible: bool = True, ): self._limits = limits - self._direction = direction - axis = 'xyz'.index(self._direction) + self.direction = direction + axis = 'xyz'.index(self.direction) self.dim = self._limits[axis].dim self._unit = self._limits[axis].unit self.visible = True self._update = update self._border_visible = border_visible - w_axis = 2 if self._direction == 'x' else 0 - h_axis = 2 if self._direction == 'y' else 1 + w_axis = 2 if self.direction == 'x' else 0 + h_axis = 2 if self.direction == 'y' else 1 width = (self._limits[w_axis][1] - self._limits[w_axis][0]).value height = (self._limits[h_axis][1] - self._limits[h_axis][0]).value @@ -95,10 +95,10 @@ def __init__( material=p3.LineBasicMaterial(color=color, linewidth=linewidth), ), ] - if self._direction == 'x': + if self.direction == 'x': for outline in self.outlines: outline.rotateY(0.5 * np.pi) - if self._direction == 'y': + if self.direction == 'y': for outline in self.outlines: outline.rotateX(0.5 * np.pi) @@ -158,20 +158,20 @@ def toggle_border(self, value: bool): # the cut, the border visibility is in sync with the parent button. self._border_visible = value - def move(self, value: dict[str, Any]): + def move(self, change: dict[str, Any]): """ Move the outline of the cut according to new position given by the slider. """ # 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() + np.abs(np.array(change['new']) - np.array(change['old'])).max() < 0.01 * self.slider.step ): return - for outline, val in zip(self.outlines, value['new'], strict=True): + for outline, val in zip(self.outlines, change['new'], strict=True): pos = list(outline.position) - axis = 'xyz'.index(self._direction) + axis = 'xyz'.index(self.direction) pos[axis] = val outline.position = pos self._throttled_update() @@ -182,6 +182,13 @@ def range(self): self.slider.value[1], unit=self._unit ) + def make_selection(self, da: sc.DataArray) -> sc.Variable: + """ + Make a selection variable based on the current slider range. + """ + xmin, xmax = self.range + return (da.coords[self.dim] >= xmin) & (da.coords[self.dim] < xmax) + @debounce(0.3) def _throttled_update(self): self._update() @@ -208,7 +215,7 @@ def __init__(self, limits: sc.Variable, update: Callable): self._unit = self._limits.unit self.visible = True self._update = update - self._direction = 'v' + self.direction = 'v' center = self._limits.mean().value vmin = self._limits[0].value @@ -233,7 +240,7 @@ def __init__(self, limits: sc.Variable, update: Callable): self.unit_label = ipw.Label(f'[{self._unit}]') self.cut_visible.on_click(self.toggle) - self.slider.observe(self._throttled_update, names='value') + self.slider.observe(self.move, names='value') super().__init__([self.slider, ipw.Label(f'[{self._unit}]'), self.cut_visible]) @@ -256,8 +263,25 @@ def range(self): self.slider.value[1], unit=self._unit ) + def make_selection(self, da: sc.DataArray) -> sc.Variable: + """ + Make a selection variable based on the current slider range. + """ + xmin, xmax = self.range + return (da.data >= xmin) & (da.data < xmax) + + def move(self, change: dict[str, Any]): + # 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(change['new']) - np.array(change['old'])).max() + < 0.01 * self.slider.step + ): + return + self._throttled_update() + @debounce(0.3) - def _throttled_update(self, _): + def _throttled_update(self): self._update() @@ -417,10 +441,7 @@ def _add_cut(self, direction: Literal['x', 'y', 'z', 'v']): Add a cut in the specified direction. """ if direction == 'v': - cut = ClipValueTool( - limits=self._value_limits, - update=self.update_state, - ) + cut = ClipValueTool(limits=self._value_limits, update=self.update_state) else: cut = Clip3dTool( direction=direction, @@ -437,7 +458,7 @@ def _add_cut(self, direction: Literal['x', 'y', 'z', 'v']): def _remove_cut(self, _): cut = self.cuts.pop(self.tabs.selected_index) - if cut._direction != 'v': + if cut.direction != 'v': self._view.canvas.remove(cut.outlines) self.tabs.children = self.cuts self.update_state() @@ -453,7 +474,7 @@ def update_controls(self): self.delete_cut.disabled = not at_least_one_cut self.cut_borders_visibility.disabled = not at_least_one_cut self.cut_operation.disabled = not at_least_one_cut - self.tabs.titles = [cut._direction.upper() for cut in self.cuts] + self.tabs.titles = [cut.direction.upper() for cut in self.cuts] self.opacity.disabled = not at_least_one_cut opacity = self.opacity.value if at_least_one_cut else 1.0 self._set_opacity({'new': opacity}) @@ -506,15 +527,7 @@ def update_state(self): for n in self._original_nodes: da = n.request_data() - selections = [] - for cut in visible_cuts: - xmin, xmax = cut.range - 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) - ) + selections = [cut.make_selection(da) for cut in visible_cuts] selection = OPERATIONS[self._operation](selections) if selection.sum().value > 0: if n.id not in self._nodes: From fc9ded448af9471953ff9b50d1e634850bda2e3b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 14:45:06 +0100 Subject: [PATCH 08/10] direction -> kind --- src/plopp/widgets/clip3d.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 844c51443..7ceb48185 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -46,7 +46,7 @@ class Clip3dTool(ipw.HBox): ---------- limits: The spatial extent of the points in the 3d figure in the XYZ directions. - direction: + kind: The direction normal to the slice. update: A function to update the scene. @@ -59,23 +59,23 @@ class Clip3dTool(ipw.HBox): def __init__( self, limits: tuple[sc.Variable, sc.Variable, sc.Variable], - direction: Literal['x', 'y', 'z'], + kind: Literal['x', 'y', 'z'], update: Callable, color: str = 'red', linewidth: float = 1.5, border_visible: bool = True, ): self._limits = limits - self.direction = direction - axis = 'xyz'.index(self.direction) + self.kind = kind + axis = 'xyz'.index(self.kind) self.dim = self._limits[axis].dim self._unit = self._limits[axis].unit self.visible = True self._update = update self._border_visible = border_visible - w_axis = 2 if self.direction == 'x' else 0 - h_axis = 2 if self.direction == 'y' else 1 + w_axis = 2 if self.kind == 'x' else 0 + h_axis = 2 if self.kind == 'y' else 1 width = (self._limits[w_axis][1] - self._limits[w_axis][0]).value height = (self._limits[h_axis][1] - self._limits[h_axis][0]).value @@ -95,10 +95,10 @@ def __init__( material=p3.LineBasicMaterial(color=color, linewidth=linewidth), ), ] - if self.direction == 'x': + if self.kind == 'x': for outline in self.outlines: outline.rotateY(0.5 * np.pi) - if self.direction == 'y': + if self.kind == 'y': for outline in self.outlines: outline.rotateX(0.5 * np.pi) @@ -112,7 +112,7 @@ def __init__( max=vmax, value=[center[axis] - delta, center[axis] + delta], step=dx * 0.01, - description=direction.upper(), + description=self.kind.upper(), style={'description_width': 'initial'}, layout={'width': '35.6em', 'padding': '0px'}, ) @@ -171,7 +171,7 @@ def move(self, change: dict[str, Any]): return for outline, val in zip(self.outlines, change['new'], strict=True): pos = list(outline.position) - axis = 'xyz'.index(self.direction) + axis = 'xyz'.index(self.kind) pos[axis] = val outline.position = pos self._throttled_update() @@ -205,7 +205,7 @@ class ClipValueTool(ipw.HBox): Parameters ---------- limits: - The spatial extent of the points in the 3d figure in the XYZ directions. + The range of values in the original data. update: A function to update the scene. """ @@ -215,7 +215,7 @@ def __init__(self, limits: sc.Variable, update: Callable): self._unit = self._limits.unit self.visible = True self._update = update - self.direction = 'v' + self.kind = 'v' center = self._limits.mean().value vmin = self._limits[0].value @@ -436,15 +436,15 @@ def __init__(self, fig: BaseFig): self.layout.display = 'none' - def _add_cut(self, direction: Literal['x', 'y', 'z', 'v']): + def _add_cut(self, kind: Literal['x', 'y', 'z', 'v']): """ - Add a cut in the specified direction. + Add a cut of the specified kind. """ - if direction == 'v': + if kind == 'v': cut = ClipValueTool(limits=self._value_limits, update=self.update_state) else: cut = Clip3dTool( - direction=direction, + kind=kind, limits=self._limits, update=self.update_state, border_visible=self.cut_borders_visibility.value, @@ -458,7 +458,7 @@ def _add_cut(self, direction: Literal['x', 'y', 'z', 'v']): def _remove_cut(self, _): cut = self.cuts.pop(self.tabs.selected_index) - if cut.direction != 'v': + if cut.kind != 'v': self._view.canvas.remove(cut.outlines) self.tabs.children = self.cuts self.update_state() @@ -474,7 +474,7 @@ def update_controls(self): self.delete_cut.disabled = not at_least_one_cut self.cut_borders_visibility.disabled = not at_least_one_cut self.cut_operation.disabled = not at_least_one_cut - self.tabs.titles = [cut.direction.upper() for cut in self.cuts] + self.tabs.titles = [cut.kind.upper() for cut in self.cuts] self.opacity.disabled = not at_least_one_cut opacity = self.opacity.value if at_least_one_cut else 1.0 self._set_opacity({'new': opacity}) From 3d85a438d06ec849e1f5b769a508b548440ba40d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 14:54:19 +0100 Subject: [PATCH 09/10] fix class name in tests --- tests/widgets/clip3d_test.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 5f48bac0b..1541dc181 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -6,7 +6,7 @@ from plopp import Node from plopp.data.testing import data_array, scatter from plopp.graphics import scatter3dfigure -from plopp.widgets import ClippingPlanes +from plopp.widgets import ClippingManager @pytest.mark.parametrize('multiple_nodes', [False, True]) @@ -19,7 +19,7 @@ def test_add_remove_cuts(multiple_nodes): nodes.append(Node(b)) fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=True) - clip = ClippingPlanes(fig) + clip = ClippingManager(fig) assert len(fig.artists) == 1 * len(nodes) clip.add_x_cut.click() assert len(fig.artists) == 2 * len(nodes) @@ -55,7 +55,7 @@ def test_value_cuts(multiple_nodes): 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 = ClippingManager(fig) clip.add_v_cut.click() vcut = clip.cuts[-1] npoints = list(fig.artists.values())[-1]._data.shape[0] @@ -96,7 +96,7 @@ def test_mixing_spatial_and_value_cuts(multiple_nodes): 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 = ClippingManager(fig) # Add a spatial cut clip.add_y_cut.click() @@ -120,7 +120,7 @@ def test_mixing_spatial_and_value_cuts(multiple_nodes): def test_move_cut(): da = scatter() fig = scatter3dfigure(Node(da), x='x', y='y', z='z', cbar=True) - clip = ClippingPlanes(fig) + clip = ClippingManager(fig) clip.add_x_cut.click() xcut = clip.cuts[-1] assert xcut.outlines[0].position[0] == xcut.slider.value[0] @@ -139,7 +139,7 @@ def test_operation_or(): dim = 'pix' da = data_array(ndim=3).flatten(to=dim) fig = scatter3dfigure(Node(da), x='xx', y='yy', z='zz', cbar=True) - clip = ClippingPlanes(fig) + clip = ClippingManager(fig) clip.cut_operation.value = 'OR' clip.add_x_cut.click() @@ -177,7 +177,7 @@ def test_operation_and(): dim = 'pix' da = data_array(ndim=3).flatten(to=dim) fig = scatter3dfigure(Node(da), x='xx', y='yy', z='zz', cbar=True) - clip = ClippingPlanes(fig) + clip = ClippingManager(fig) clip.cut_operation.value = 'AND' clip.add_x_cut.click() @@ -212,7 +212,7 @@ def test_operation_xor(): dim = 'pix' da = data_array(ndim=3).flatten(to=dim) fig = scatter3dfigure(Node(da), x='xx', y='yy', z='zz', cbar=True) - clip = ClippingPlanes(fig) + clip = ClippingManager(fig) clip.cut_operation.value = 'XOR' clip.add_x_cut.click() From 5d29903c4e0473d1691e85ec3ab013c1a44634c5 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Feb 2026 15:42:44 +0100 Subject: [PATCH 10/10] update api reference --- docs/api-reference/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 7195de8a1..6d73f1c95 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -79,7 +79,8 @@ widgets.drawing.PointsTool widgets.clip3d.Clip3dTool - widgets.clip3d.ClippingPlanes + widgets.clip3d.ClipValueTool + widgets.clip3d.ClippingManager ``` ## Backends