init
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
"""Affine widget module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
import pyvista
|
||||
from pyvista._deprecate_positional_args import _deprecate_positional_args
|
||||
from pyvista.core.errors import VTKVersionError
|
||||
from pyvista.core.utilities.misc import _NoNewAttrMixin
|
||||
from pyvista.core.utilities.misc import try_callback
|
||||
|
||||
from . import _vtk
|
||||
|
||||
DARK_YELLOW = (0.9647058823529412, 0.7450980392156863, 0)
|
||||
GLOBAL_AXES = np.eye(3)
|
||||
|
||||
|
||||
def _validate_axes(axes):
|
||||
"""Validate and normalize input axes.
|
||||
|
||||
Axes are expected to follow the right-hand rule (e.g. third axis is the
|
||||
cross product of the first two.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
axes : sequence
|
||||
The axes to be validated and normalized. Should be of shape (3, 3).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The validated and normalized axes.
|
||||
|
||||
"""
|
||||
axes = np.array(axes)
|
||||
if axes.shape != (3, 3):
|
||||
msg = '`axes` must be a (3, 3) array.'
|
||||
raise ValueError(msg)
|
||||
|
||||
axes = axes / np.linalg.norm(axes, axis=1, keepdims=True)
|
||||
if not np.allclose(np.cross(axes[0], axes[1]), axes[2]):
|
||||
msg = '`axes` do not follow the right hand rule.'
|
||||
raise ValueError(msg)
|
||||
|
||||
return axes
|
||||
|
||||
|
||||
def _check_callable(func, name='callback'):
|
||||
"""Check if a variable is callable."""
|
||||
if func and not callable(func):
|
||||
msg = f'`{name}` must be a callable, not {type(func)}.'
|
||||
raise TypeError(msg)
|
||||
return func
|
||||
|
||||
|
||||
def _make_quarter_arc():
|
||||
"""Make a quarter circle centered at the origin."""
|
||||
circ = pyvista.Circle(resolution=100)
|
||||
circ.faces = np.empty(0, dtype=int)
|
||||
circ.lines = np.hstack(([26], np.arange(0, 26)))
|
||||
return circ
|
||||
|
||||
|
||||
def get_angle(v1, v2):
|
||||
"""Compute the angle between two vectors in degrees.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
v1 : numpy.ndarray
|
||||
First input vector.
|
||||
v2 : numpy.ndarray
|
||||
Second input vector.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
Angle between vectors in degrees.
|
||||
|
||||
"""
|
||||
return np.rad2deg(np.arccos(np.clip(np.dot(v1, v2), -1.0, 1.0)))
|
||||
|
||||
|
||||
@_deprecate_positional_args
|
||||
def ray_plane_intersection(start_point, direction, plane_point, normal): # noqa: PLR0917
|
||||
"""Compute the intersection between a ray and a plane.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start_point : ndarray
|
||||
Starting point of the ray.
|
||||
direction : ndarray
|
||||
Direction of the ray.
|
||||
plane_point : ndarray
|
||||
A point on the plane.
|
||||
normal : ndarray
|
||||
Normal to the plane.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray
|
||||
Intersection point.
|
||||
|
||||
"""
|
||||
t_value = np.dot(normal, (plane_point - start_point)) / np.dot(normal, direction)
|
||||
return start_point + t_value * direction
|
||||
|
||||
|
||||
class AffineWidget3D(_NoNewAttrMixin):
|
||||
"""3D affine transform widget.
|
||||
|
||||
This widget allows interactive transformations including translation and
|
||||
rotation using the left mouse button.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
plotter : pyvista.Plotter
|
||||
The plotter object.
|
||||
actor : pyvista.Actor
|
||||
The actor to which the widget is attached to.
|
||||
origin : sequence[float], optional
|
||||
Origin of the widget. Default is the center of the main actor.
|
||||
start : bool, default: True
|
||||
If True, start the widget immediately.
|
||||
scale : float, default: 0.15
|
||||
Scale factor for the widget relative to the length of the actor.
|
||||
line_radius : float, default: 0.02
|
||||
Relative radius of the lines composing the widget.
|
||||
always_visible : bool, default: True
|
||||
Make the widget always visible. Setting this to ``False`` will cause
|
||||
the widget geometry to be hidden by other actors in the plotter.
|
||||
axes_colors : tuple[ColorLike], optional
|
||||
Uses the theme by default. Configure the individual axis colors by
|
||||
modifying either the theme with ``pyvista.global_theme.axes.x_color =
|
||||
<COLOR>`` or setting this with a ``tuple`` as in ``('r', 'g', 'b')``.
|
||||
axes : numpy.ndarray, optional
|
||||
``(3, 3)`` Numpy array defining the X, Y, and Z axes. By default this
|
||||
matches the default coordinate system.
|
||||
release_callback : callable, optional
|
||||
Call this method when releasing the left mouse button. It is passed the
|
||||
``user_matrix`` of the actor.
|
||||
interact_callback : callable, optional
|
||||
Call this method when moving the mouse with the left mouse button
|
||||
pressed down and a valid movement actor selected. It is passed the
|
||||
``user_matrix`` of the actor.
|
||||
|
||||
Notes
|
||||
-----
|
||||
After interacting with the actor, the transform will be stored within
|
||||
:attr:`pyvista.Prop3D.user_matrix` but will not be applied to the
|
||||
dataset. Use this matrix in conjunction with
|
||||
:func:`pyvista.DataObjectFilters.transform` to transform the dataset.
|
||||
|
||||
Requires VTK >= v9.2
|
||||
|
||||
Examples
|
||||
--------
|
||||
Create the affine widget outside of the plotter and add it.
|
||||
|
||||
>>> import pyvista as pv
|
||||
>>> pl = pv.Plotter()
|
||||
>>> actor = pl.add_mesh(pv.Sphere())
|
||||
>>> widget = pv.AffineWidget3D(pl, actor)
|
||||
>>> pl.show()
|
||||
|
||||
Access the transform from the actor.
|
||||
|
||||
>>> actor.user_matrix
|
||||
array([[1., 0., 0., 0.],
|
||||
[0., 1., 0., 0.],
|
||||
[0., 0., 1., 0.],
|
||||
[0., 0., 0., 1.]])
|
||||
|
||||
"""
|
||||
|
||||
@_deprecate_positional_args(allowed=['plotter', 'actor'])
|
||||
def __init__( # noqa: PLR0917
|
||||
self,
|
||||
plotter,
|
||||
actor,
|
||||
origin=None,
|
||||
start: bool = True, # noqa: FBT001, FBT002
|
||||
scale=0.15,
|
||||
line_radius=0.02,
|
||||
always_visible: bool = True, # noqa: FBT001, FBT002
|
||||
axes_colors=None,
|
||||
axes=None,
|
||||
release_callback=None,
|
||||
interact_callback=None,
|
||||
):
|
||||
"""Initialize the widget."""
|
||||
# needs VTK v9.2.0 due to the hardware picker
|
||||
if pyvista.vtk_version_info < (9, 2):
|
||||
msg = 'AfflineWidget3D requires VTK v9.2.0 or newer.'
|
||||
raise VTKVersionError(msg)
|
||||
|
||||
self._axes = np.eye(4)
|
||||
self._axes_inv = np.eye(4)
|
||||
self._pl = plotter
|
||||
self._main_actor = actor
|
||||
self._selected_actor: pyvista.Actor | None = None
|
||||
self._init_position = None
|
||||
self._mouse_move_observer: int | None = None
|
||||
self._left_press_observer: int | None = None
|
||||
self._left_release_observer: int | None = None
|
||||
|
||||
if self._main_actor.user_matrix is None:
|
||||
self._main_actor.user_matrix = np.eye(4)
|
||||
self._cached_matrix = self._main_actor.user_matrix
|
||||
|
||||
self._arrows = [] # type: ignore[var-annotated]
|
||||
self._circles = [] # type: ignore[var-annotated]
|
||||
self._pressing_down = False
|
||||
origin = origin or actor.center
|
||||
self._origin = np.array(origin)
|
||||
if axes_colors is None:
|
||||
axes_colors = (
|
||||
pyvista.global_theme.axes.x_color,
|
||||
pyvista.global_theme.axes.y_color,
|
||||
pyvista.global_theme.axes.z_color,
|
||||
)
|
||||
self._axes_colors = axes_colors
|
||||
self._circ = _make_quarter_arc()
|
||||
self._actor_length = self._main_actor.GetLength()
|
||||
self._line_radius = line_radius
|
||||
self._user_interact_callback = _check_callable(interact_callback)
|
||||
self._user_release_callback = _check_callable(release_callback)
|
||||
|
||||
self._init_actors(scale, always_visible)
|
||||
|
||||
# axes must be set after initializing actors
|
||||
if axes is not None:
|
||||
try:
|
||||
_validate_axes(axes)
|
||||
except ValueError:
|
||||
for actor_ in self._arrows + self._circles:
|
||||
self._pl.remove_actor(actor_)
|
||||
raise
|
||||
self.axes = axes
|
||||
|
||||
if start:
|
||||
self.enable()
|
||||
|
||||
def _init_actors(self, scale, always_visible):
|
||||
"""Initialize the widget's actors."""
|
||||
for ii, color in enumerate(self._axes_colors):
|
||||
arrow = pyvista.Arrow(
|
||||
start=(0, 0, 0),
|
||||
direction=GLOBAL_AXES[ii],
|
||||
scale=self._actor_length * scale * 1.15,
|
||||
tip_radius=0.05,
|
||||
shaft_radius=self._line_radius,
|
||||
)
|
||||
self._arrows.append(
|
||||
self._pl.add_mesh(arrow, color=color, lighting=False, render=False)
|
||||
)
|
||||
axis_circ = self._circ.copy()
|
||||
if ii == 0:
|
||||
axis_circ = axis_circ.rotate_y(-90)
|
||||
elif ii == 1:
|
||||
axis_circ = axis_circ.rotate_x(90)
|
||||
axis_circ.points *= self._main_actor.GetLength() * (scale * 1.6)
|
||||
# axis_circ.points += self._origin
|
||||
axis_circ = axis_circ.tube(
|
||||
radius=self._line_radius * self._actor_length * scale,
|
||||
absolute=True,
|
||||
radius_factor=1.0,
|
||||
)
|
||||
|
||||
self._circles.append(
|
||||
self._pl.add_mesh(
|
||||
axis_circ,
|
||||
color=color,
|
||||
lighting=False,
|
||||
render_lines_as_tubes=True,
|
||||
render=False,
|
||||
),
|
||||
)
|
||||
|
||||
# update origin and assign a default user_matrix
|
||||
for actor in self._arrows + self._circles:
|
||||
matrix = np.eye(4)
|
||||
matrix[:3, -1] = self._origin
|
||||
actor.user_matrix = matrix
|
||||
|
||||
if always_visible:
|
||||
for actor in self._arrows + self._circles:
|
||||
actor.mapper.SetResolveCoincidentTopologyToPolygonOffset()
|
||||
actor.mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(0, -20000)
|
||||
|
||||
def _get_world_coord_rot(self, interactor):
|
||||
"""Get the world coordinates given an interactor.
|
||||
|
||||
Unlike ``_get_world_coord_trans``, these coordinates are physically
|
||||
accurate, but sensitive to the position of the camera. Rotation is zoom
|
||||
independent.
|
||||
|
||||
"""
|
||||
x, y = interactor.GetEventPosition()
|
||||
coordinate = _vtk.vtkCoordinate()
|
||||
coordinate.SetCoordinateSystemToDisplay()
|
||||
coordinate.SetValue(x, y, 0)
|
||||
ren = interactor.GetRenderWindow().GetRenderers().GetFirstRenderer()
|
||||
point = np.array(coordinate.GetComputedWorldValue(ren))
|
||||
if self._selected_actor:
|
||||
index = self._circles.index(self._selected_actor)
|
||||
to_widget = np.array(ren.camera.position - self._origin)
|
||||
point = ray_plane_intersection(
|
||||
start_point=point,
|
||||
direction=to_widget,
|
||||
plane_point=self._origin,
|
||||
normal=self.axes[index],
|
||||
)
|
||||
return point
|
||||
|
||||
def _get_world_coord_trans(self, interactor):
|
||||
"""Get the world coordinates given an interactor.
|
||||
|
||||
This uses a modified scaled approach to get the world coordinates that
|
||||
are not physically accurate, but ignores zoom and works for
|
||||
translation.
|
||||
|
||||
"""
|
||||
x, y = interactor.GetEventPosition()
|
||||
ren = interactor.GetRenderWindow().GetRenderers().GetFirstRenderer()
|
||||
|
||||
# Get normalized view coordinates (-1, 1)
|
||||
width, height = ren.GetSize()
|
||||
ndc_x = 2 * (x / width) - 1
|
||||
ndc_y = 2 * (y / height) - 1
|
||||
ndc_z = 1
|
||||
|
||||
# convert camera coordinates to world coordinates
|
||||
camera = ren.GetActiveCamera()
|
||||
projection_matrix = pyvista.array_from_vtkmatrix(
|
||||
camera.GetProjectionTransformMatrix(ren.GetTiledAspectRatio(), 0, 1),
|
||||
)
|
||||
inverse_projection_matrix = np.linalg.inv(projection_matrix)
|
||||
camera_coords = np.dot(inverse_projection_matrix, [ndc_x, ndc_y, ndc_z, 1])
|
||||
modelview_matrix = pyvista.array_from_vtkmatrix(camera.GetModelViewTransformMatrix())
|
||||
inverse_modelview_matrix = np.linalg.inv(modelview_matrix)
|
||||
world_coords = np.dot(inverse_modelview_matrix, camera_coords)
|
||||
|
||||
# Scale by twice actor length (experimentally determined for good UX)
|
||||
return world_coords[:3] * self._actor_length * 2
|
||||
|
||||
def _move_callback(self, interactor, _event):
|
||||
"""Process actions for the move mouse event."""
|
||||
click_x, click_y = interactor.GetEventPosition()
|
||||
click_z = 0
|
||||
picker = interactor.GetPicker()
|
||||
renderer = interactor.GetInteractorStyle()._parent()._plotter.iren.get_poked_renderer()
|
||||
picker.Pick(click_x, click_y, click_z, renderer)
|
||||
actor = picker.GetActor()
|
||||
|
||||
if self._pressing_down:
|
||||
if self._selected_actor in self._arrows:
|
||||
current_pos = self._get_world_coord_trans(interactor)
|
||||
index = self._arrows.index(self._selected_actor)
|
||||
diff = current_pos - self._init_position
|
||||
trans_matrix = np.eye(4)
|
||||
trans_matrix[:3, -1] = self.axes[index] * np.dot(diff, self.axes[index])
|
||||
matrix = trans_matrix @ self._cached_matrix
|
||||
elif self._selected_actor in self._circles:
|
||||
current_pos = self._get_world_coord_rot(interactor)
|
||||
index = self._circles.index(self._selected_actor)
|
||||
vec_current = current_pos - self._origin
|
||||
vec_init = self._init_position - self._origin
|
||||
normal = self.axes[index]
|
||||
vec_current = vec_current - np.dot(vec_current, normal) * normal
|
||||
vec_init = vec_init - np.dot(vec_init, normal) * normal
|
||||
vec_current /= np.linalg.norm(vec_current)
|
||||
vec_init /= np.linalg.norm(vec_init)
|
||||
angle = get_angle(vec_init, vec_current)
|
||||
cross = np.cross(vec_init, vec_current)
|
||||
if cross[index] < 0:
|
||||
angle = -angle
|
||||
|
||||
trans = _vtk.vtkTransform()
|
||||
trans.Translate(self._origin) # type: ignore[call-overload]
|
||||
trans.RotateWXYZ(
|
||||
angle,
|
||||
self._axes[index][0],
|
||||
self._axes[index][1],
|
||||
self._axes[index][2],
|
||||
)
|
||||
trans.Translate(-self._origin) # type: ignore[call-overload]
|
||||
trans.Update()
|
||||
rot_matrix = pyvista.array_from_vtkmatrix(trans.GetMatrix())
|
||||
matrix = rot_matrix @ self._cached_matrix
|
||||
|
||||
if self._user_interact_callback:
|
||||
try_callback(self._user_interact_callback, self._main_actor.user_matrix)
|
||||
|
||||
self._main_actor.user_matrix = matrix
|
||||
|
||||
elif self._selected_actor and self._selected_actor is not actor:
|
||||
# Return the color of the currently selected actor to normal and
|
||||
# deselect it
|
||||
if self._selected_actor in self._arrows:
|
||||
index = self._arrows.index(self._selected_actor)
|
||||
elif self._selected_actor in self._circles:
|
||||
index = self._circles.index(self._selected_actor)
|
||||
self._selected_actor.prop.color = self._axes_colors[index]
|
||||
self._selected_actor = None
|
||||
|
||||
# Highlight the actor if there is no selected actor
|
||||
if actor and not self._selected_actor:
|
||||
if actor in self._arrows:
|
||||
index = self._arrows.index(actor)
|
||||
self._arrows[index].prop.color = DARK_YELLOW
|
||||
actor.prop.color = DARK_YELLOW
|
||||
self._selected_actor = actor
|
||||
elif actor in self._circles:
|
||||
index = self._circles.index(actor)
|
||||
self._circles[index].prop.color = DARK_YELLOW
|
||||
actor.prop.color = DARK_YELLOW
|
||||
self._selected_actor = actor
|
||||
self._pl.render()
|
||||
|
||||
def _press_callback(self, interactor, _event):
|
||||
"""Process actions for the mouse button press event."""
|
||||
if self._selected_actor:
|
||||
self._pl.enable_trackball_actor_style()
|
||||
self._pressing_down = True
|
||||
if self._selected_actor in self._circles:
|
||||
self._init_position = self._get_world_coord_rot(interactor)
|
||||
else:
|
||||
self._init_position = self._get_world_coord_trans(interactor)
|
||||
|
||||
def _release_callback(self, _interactor, _event):
|
||||
"""Process actions for the mouse button release event."""
|
||||
self._pl.enable_trackball_style()
|
||||
self._pressing_down = False
|
||||
self._cached_matrix = self._main_actor.user_matrix
|
||||
if self._user_release_callback:
|
||||
try_callback(self._user_release_callback, self._main_actor.user_matrix)
|
||||
|
||||
def _reset(self):
|
||||
"""Reset the actor and cached transform."""
|
||||
self._main_actor.user_matrix = np.eye(4)
|
||||
self._cached_matrix = np.eye(4)
|
||||
|
||||
@property
|
||||
def axes(self):
|
||||
"""Return or set the axes of the widget.
|
||||
|
||||
The axes will be checked for orthogonality. Non-orthogonal axes will
|
||||
raise a ``ValueError``
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
``(3, 3)`` array of axes.
|
||||
|
||||
"""
|
||||
return self._axes[:3, :3]
|
||||
|
||||
@axes.setter
|
||||
def axes(self, axes):
|
||||
mat = np.eye(4)
|
||||
mat[:3, :3] = _validate_axes(axes)
|
||||
mat[:3, -1] = self.origin
|
||||
self._axes = mat
|
||||
self._axes_inv = np.linalg.inv(self._axes) # type: ignore[assignment]
|
||||
for actor in self._arrows + self._circles:
|
||||
matrix = actor.user_matrix
|
||||
# Be sure to use the inverse here
|
||||
matrix[:3, :3] = self._axes_inv[:3, :3]
|
||||
actor.user_matrix = matrix
|
||||
|
||||
@property
|
||||
def origin(self) -> tuple[float, float, float]:
|
||||
"""Origin of the widget.
|
||||
|
||||
This is where the origin of the widget will be located and where the
|
||||
actor will be rotated about.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple
|
||||
Widget origin.
|
||||
|
||||
"""
|
||||
return cast('tuple[float, float, float]', tuple(self._origin))
|
||||
|
||||
@origin.setter
|
||||
def origin(self, value):
|
||||
value = np.array(value)
|
||||
diff = value - self._origin
|
||||
|
||||
for actor in self._circles + self._arrows:
|
||||
if actor.user_matrix is None:
|
||||
actor.user_matrix = np.eye(4)
|
||||
matrix = actor.user_matrix
|
||||
matrix[:3, -1] += diff
|
||||
actor.user_matrix = matrix
|
||||
|
||||
self._origin = value
|
||||
|
||||
def enable(self):
|
||||
"""Enable the widget."""
|
||||
if not self._pl._picker_in_use:
|
||||
self._pl.enable_mesh_picking(show_message=False, show=False, picker='hardware')
|
||||
self._mouse_move_observer = self._pl.iren.add_observer(
|
||||
'MouseMoveEvent',
|
||||
self._move_callback,
|
||||
)
|
||||
self._left_press_observer = self._pl.iren.add_observer(
|
||||
'LeftButtonPressEvent',
|
||||
self._press_callback,
|
||||
interactor_style_fallback=False,
|
||||
)
|
||||
self._left_release_observer = self._pl.iren.add_observer(
|
||||
'LeftButtonReleaseEvent',
|
||||
self._release_callback,
|
||||
interactor_style_fallback=False,
|
||||
)
|
||||
|
||||
def disable(self):
|
||||
"""Disable the widget."""
|
||||
self._pl.disable_picking()
|
||||
if self._mouse_move_observer:
|
||||
self._pl.iren.remove_observer(self._mouse_move_observer)
|
||||
if self._left_press_observer:
|
||||
self._pl.iren.remove_observer(self._left_press_observer)
|
||||
if self._left_release_observer:
|
||||
self._pl.iren.remove_observer(self._left_release_observer)
|
||||
|
||||
def remove(self):
|
||||
"""Disable and delete all actors of this widget."""
|
||||
self.disable()
|
||||
for actor in self._circles + self._arrows:
|
||||
self._pl.remove_actor(actor)
|
||||
self._circles = []
|
||||
self._arrows = []
|
||||
Reference in New Issue
Block a user