Source code for plopp.widgets.clip3d
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
import uuid
from collections.abc import Callable
from functools import partial, reduce
from typing import Any, Literal
import ipywidgets as ipw
import numpy as np
import scipp as sc
from ..core import Node
from ..graphics import BaseFig
from .debounce import debounce
from .style import BUTTON_LAYOUT
def _xor(x: list[sc.Variable]) -> sc.Variable:
dim = uuid.uuid4().hex
return sc.concat(x, dim).sum(dim) == sc.scalar(1, unit=None)
OPERATIONS = {
'or': partial(reduce, lambda x, y: sc.logical_or(x, y)),
'and': partial(reduce, lambda x, y: sc.logical_and(x, y)),
'xor': _xor,
}
def select(da: sc.DataArray, s: tuple[str, sc.Variable]) -> sc.DataArray:
return da[s]
[docs]
class Clip3dTool(ipw.HBox):
"""
A tool that provides a slider to extract a slab of points in a three-dimensional
scatter plot, and add it to the scene as an opaque cut. The slider controls the
position and range of the slice. When the slider is dragged, the red outline of the
cut is moved at the same time, while the actual point cloud gets updated less
frequently using a debounce mechanism.
.. versionadded:: 24.04.0
Parameters
----------
limits:
The spatial extent of the points in the 3d figure in the XYZ directions.
direction:
The direction normal to the slice.
update:
A function to update the scene.
color:
Color of the cut's outline.
linewidth:
Width of the line delineating the outline.
"""
[docs]
def __init__(
self,
limits: tuple[sc.Variable, sc.Variable, sc.Variable],
direction: 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.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
width = (self._limits[w_axis][1] - self._limits[w_axis][0]).value
height = (self._limits[h_axis][1] - self._limits[h_axis][0]).value
import pythreejs as p3
self.outlines = [
p3.LineSegments(
geometry=p3.EdgesGeometry(
p3.PlaneBufferGeometry(width=width, height=height)
),
material=p3.LineBasicMaterial(color=color, linewidth=linewidth),
),
p3.LineSegments(
geometry=p3.EdgesGeometry(
p3.PlaneBufferGeometry(width=width, height=height)
),
material=p3.LineBasicMaterial(color=color, linewidth=linewidth),
),
]
if self._direction == 'x':
for outline in self.outlines:
outline.rotateY(0.5 * np.pi)
if self._direction == 'y':
for outline in self.outlines:
outline.rotateX(0.5 * np.pi)
center = [var.mean().value for var in self._limits]
vmin = self._limits[axis][0].value
vmax = self._limits[axis][1].value
dx = vmax - vmin
delta = 0.05 * dx
self.slider = ipw.FloatRangeSlider(
min=vmin,
max=vmax,
value=[center[axis] - delta, center[axis] + delta],
step=dx * 0.01,
description=direction.upper(),
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'},
)
for outline, val in zip(self.outlines, self.slider.value, strict=True):
pos = list(center)
pos[axis] = val
outline.position = pos
outline.visible = self._border_visible
self.unit_label = ipw.Label(f'[{self._unit}]')
self.cut_visible.on_click(self.toggle)
self.slider.observe(self.move, 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
for outline in self.outlines:
outline.visible = self.visible and self._border_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):
"""
Toggle the border visibility.
"""
for outline in self.outlines:
outline.visible = value
# The call to this function comes from the parent widget, so we need to
# remember the state of the button, so that when we toggle the visbiility of
# the cut, the border visibility is in sync with the parent button.
self._border_visible = value
def move(self, value: 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()
< 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()
[docs]
class ClippingPlanes(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.
.. versionadded:: 24.04.0
Parameters
----------
fig:
The 3d figure that contains the point clouds to be cut.
"""
[docs]
def __init__(self, fig: BaseFig):
self._view = fig.view
bbox = self._view.bbox
canvas = self._view.canvas
self._limits = (
sc.array(
dims=[canvas.dims['x']],
values=[bbox.xmin, bbox.xmax],
unit=canvas.units['x'],
),
sc.array(
dims=[canvas.dims['y']],
values=[bbox.ymin, bbox.ymax],
unit=canvas.units['y'],
),
sc.array(
dims=[canvas.dims['z']],
values=[bbox.zmin, bbox.zmax],
unit=canvas.units['z'],
),
)
self.cuts = []
self._operation = 'or'
self.tabs = ipw.Tab(layout={'width': '550px'})
self._original_nodes = list(self._view.graph_nodes.values())
self._nodes = {}
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,
)
self.add_y_cut = ipw.Button(
description='Y',
icon='plus',
tooltip='Add Y cut',
layout=layout,
)
self.add_z_cut = ipw.Button(
description='Z',
icon='plus',
tooltip='Add Z 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.opacity = ipw.BoundedFloatText(
min=0,
max=0.5,
step=0.01,
disabled=True,
value=0.03,
description='Opacity:',
tooltip='Set the opacity of the background',
style={'description_width': 'initial'},
layout={'width': '142px', 'padding': '0px 0px 0px 0px'},
)
self.opacity.observe(self._set_opacity, names='value')
self.cut_borders_visibility = ipw.ToggleButton(
value=True,
disabled=True,
icon='border-style',
tooltip='Toggle visibility of the borders of the cuts',
**BUTTON_LAYOUT,
)
self.cut_borders_visibility.observe(
self.toggle_border_visibility, names='value'
)
self.cut_operation = ipw.Dropdown(
options=['OR', 'AND', 'XOR'],
value='OR',
disabled=True,
tooltip='Operation to combine multiple cuts',
layout={'width': '60px', 'padding': '0px 0px 0px 0px'},
)
self.cut_operation.observe(self.change_operation, names='value')
self.delete_cut = ipw.Button(
tooltip='Delete cut',
icon='trash',
disabled=True,
**BUTTON_LAYOUT,
)
self.delete_cut.on_click(self._remove_cut)
super().__init__(
[
self.tabs,
ipw.VBox(
[
ipw.HBox([self.add_x_cut, self.add_y_cut, self.add_z_cut]),
self.opacity,
ipw.HBox(
[
self.cut_borders_visibility,
self.cut_operation,
self.delete_cut,
]
),
]
),
]
)
self.layout.display = 'none'
def _add_cut(self, direction: Literal['x', 'y', 'z']):
"""
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)
self.cuts.append(cut)
self.tabs.children = [*self.tabs.children, cut]
self.tabs.selected_index = len(self.cuts) - 1
self.update_controls()
self.update_state()
def _remove_cut(self, _):
cut = self.cuts.pop(self.tabs.selected_index)
self._view.canvas.remove(cut.outlines)
self.tabs.children = self.cuts
self.update_state()
self.update_controls()
def update_controls(self):
"""
If there are no active cuts, disable the controls.
If there is at least one cut, set the opacity of the original children
(not the cuts) to a low value, otherwise set it back to 1.
"""
at_least_one_cut = any(1 for cut in self.cuts if cut.visible)
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.opacity.disabled = not at_least_one_cut
opacity = self.opacity.value if at_least_one_cut else 1.0
self._set_opacity({'new': opacity})
def _set_opacity(self, change: dict[str, Any]):
"""
Set the opacity of the original point clouds in the figure, not the cuts.
"""
for n in self._original_nodes:
self._view.artists[n.id].opacity = change['new']
def toggle_visibility(self):
"""
Toggle the visibility of the control buttons for making the cuts.
"""
self.layout.display = None if self.layout.display == 'none' else 'none'
def toggle_border_visibility(self, change: dict[str, Any]):
"""
Toggle the visibility of the borders of the cuts.
"""
for cut in self.cuts:
cut.toggle_border(change['new'])
def change_operation(self, change: dict[str, Any]):
"""
Change the operation to combine multiple cuts.
"""
self._operation = change['new'].lower()
self.update_state()
def update_state(self):
"""
Update the state, combining all the active cuts, using the selected binary
operation. The resulting selection is then used to either create or update a
second point cloud which is included in the scene.
The original point cloud is then set to be semi-transparent.
When the position/range of a cut is changed, this function is called via a
debounce mechanism to avoid updating the cloud too often. Only the outlines of
the cuts are moved in real time, which is cheap.
"""
for nodes in self._nodes.values():
self._view.remove(nodes['slice'].id)
nodes['slice'].remove()
self._nodes.clear()
visible_cuts = [cut for cut in self.cuts if cut.visible]
if not visible_cuts:
return
for n in self._original_nodes:
da = n.request_data()
selections = []
for cut in visible_cuts:
xmin, xmax = cut.range
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:
select_node = Node(selection)
self._nodes[n.id] = {
'select': select_node,
'slice': Node(lambda da, s: da[s], da=n, s=select_node),
}
self._nodes[n.id]['slice'].add_view(self._view)
else:
self._nodes[n.id]['select'].func = lambda: selection # noqa: B023
self._nodes[n.id]['select'].notify_children("")