Source code for vtkplotlib._interactive

# -*- coding: utf-8 -*-
"""
"""

import sys as _sys
import re as _re
import numpy as np

from collections.abc import Mapping

from vtkplotlib._get_vtk import vtk

vtkCommands = [
    i for (i, j) in vars(vtk.vtkCommand).items() if isinstance(j, int)
]


[docs]def null_super_callback(): """A placeholder callback for when an event doesn't have a parent callback which needs calling. Calling this function has no effect.""" pass
[docs]class SuperError(RuntimeError): """Raised if `get_super_callback()` or `call_super_callback()` are called in an inappropriate context. i.e. Outside of a callback. """ def __str__(self): return ("Couldn't determine the event `invoker and `event_name`. " "Ensure you are calling %s() from a callback which is " "receiving a vtkObject and str as its arguments." % self.args)
[docs]def get_super_callback(invoker=None, event_name=None): """Finds the original VTK callback function for a given event. Like the builtin `super` in Python 3.x, this method should be able to find its own arguments. :param invoker: The `vtkObject`_ you used in ``invoker.AddObserver(..)``. :type invoker: `vtkObject`_ :param event_name: The name of the interaction. :type event_name: str :return: A method of **invoker** or a dummy `null_super_callback` function. :rtype: `types.FunctionType` :raises: `SuperError` if called without (an) argument(s) and the argument(s) couldn't be determined automatically. The original callback (if there is one) is always a method of the *invoker**. VTK has some rather loose naming rules which make this deceptively fiddly. If called inside function which takes a `vtkObject`_ and a `str` as its first two arguments, then `get_super_callback()` will use the values of those two arguments as its own arguments. Or, if you provide those arguments explicitly, you can call this function anywhere. e.g. >>> vpl.i.get_super_callback(fig.style, "MouseMoveEvent") <built-in method OnMouseMove of vtkmodules.vtkInteractionStyle.vtkInteractorStyleTrackballCamera object at ...> Not all events have parent events. In these cases a dummy function is returned. This is also the case for non-existant event types. >>> vpl.i.get_super_callback(fig.style, "WindowIsCurrentEvent") <function null_super_callback at ...> >>> vpl.i.get_super_callback(fig.style, "BlueMoonEvent") <function null_super_callback at ...> Should you want to, you can also overide the one or both arguments. The following will swap the left and right mouse-click functionallities. .. code-block:: python import vtkplotlib as vpl fig = vpl.figure() vpl.quick_test_plot() def callback(invoker, event_name): # Swap left for right and vice-versa. if "Left" in event_name: swapped = event_name.replace("Left", "Right") else: swapped = event_name.replace("Right", "Left") # Call the callback for the switched event_name. vpl.i.call_super_callback(event_name=swapped) for event_name in ["LeftButtonPressEvent", "LeftButtonReleaseEvent", "RightButtonPressEvent", "RightButtonReleaseEvent"]: fig.style.AddObserver(event_name, callback) fig.show() """ if invoker is None or event_name is None: # Try to guess the arguments that would have been provided. # This uses the same frame hack that future uses to mimic super() in # Python 2. _invoker, _event_name = invoker, event_name # Find the frame that called either this method or call_super_callback(). # noinspection PyUnresolvedReferences caller = _sys._getframe(0) cb_frame = caller.f_back # If this function has been called by call_super_callback(): if cb_frame.f_code.co_name == "call_super_callback": # Go up another frame to skip the get_super_callback() frame. caller = cb_frame cb_frame = cb_frame.f_back # Guess the arguments by type rather than name. names = cb_frame.f_code.co_varnames[:cb_frame.f_code.co_argcount] f_args = (cb_frame.f_locals[i] for i in names) invoker = None for event_name in f_args: # This loop is just to bypass any `self` or `cls` 1st arguments. # It should break on its 1st or 2nd iteration. if hasattr(invoker, "AddObserver") and isinstance(event_name, str): break invoker = event_name else: raise SuperError(caller.f_code.co_name) # Allow explicitly provided arguments to override those found. invoker, event_name = _invoker or invoker, _event_name or event_name # VTK has some rather loose naming rules for callbacks and event names. name = "On" + _re.match("(.*)Event", event_name).group(1) if hasattr(invoker, name): return getattr(invoker, name) name = name.replace("Press", "Down").replace("Release", "Up") if hasattr(invoker, name): return getattr(invoker, name) # Not all callbacks have a super event. return null_super_callback
[docs]def call_super_callback(invoker=None, event_name=None): """ Just runs ``get_super_callback(invoker, event_name)()``. See `get_super_callback()`. """ get_super_callback(invoker, event_name)()
def _actor_collection(actors, collection=None): if collection is None: collection = vtk.vtkActorCollection() for actor in actors: collection.AddItem(getattr(actor, "actor", actor)) return collection def _requires_active_iren(default=None): """We must be careful when calling any method from a `pick()`'s iren in case the iren isn't initialised or when VTK's app isn't running. Otherwise this will block indefinitely. """ def _requires_active_iren_or_default(func): def wrapped(self): iren = self.style.GetInteractor() if iren and iren.GetEnabled(): return func(self, iren) return default wrapped.__doc__ = func.__doc__ return wrapped return _requires_active_iren_or_default
[docs]class pick(object): # language=rst """Pick collects information about user interactions into a handy bucket class. .. code-block:: python import vtkplotlib as vpl import numpy as np # Create a figure. fig = vpl.figure() # With something semi-interesting in it. u, v = np.meshgrid(np.linspace(-10, 10), np.linspace(-10, 10)) vpl.surface(np.sin(u) * np.sin(v), np.cos(u) * np.sin(v), v, scalars=v) def callback(invoker, event_name): vpl.i.call_super_callback() # Optional, if you're using Python in interactive mode then you can # play around with the pick afterwards. global pick # pick this current event to get a pick object. A pick contains # everything VTK has to tell you about the event. pick = vpl.i.pick(invoker) # To see everything pick has to say, just print it. print(pick) if pick.actor is None: print("Mouse is hovering over background.") else: print("Mouse is hovering over {} at (x, y, z) = {}." .format(repr(pick.actor), pick.point)) fig.style.AddObserver("MouseMoveEvent", callback) vpl.show() The most important properties of a :class:`pick` are `pick.point` which tells you the 3D coordinates of the event and `pick.actor` which tells you the `vtkActor`_ of the plot under which the event took place. """ def __init__(self, style, from_=None): self.style = style self.picker = vtk.vtkPropPicker() self.update() self.from_ = from_ @property def style(self): return self._style @style.setter def style(self, style): style = getattr(style, "style", style) if not isinstance(style, vtk.vtkInteractorStyle): raise TypeError("pick.style should be either a figure or a or a " "vtkInteractorStyle. Got a {}.".format(type(style))) self._style = style @property def from_(self): """Limit the `actor` results to only a set of actors. The (writeable) `from_` attribute allows you restrict the actors that can be picked. This can be useful, when placing markers on an object, to avoid placing a marker on top of another marker. You can set this attribute to an iterable of vtkplotlib plots, an iterable of `vtkActor`_\\ s, or a mapping with either plots or actors as its keys. On setting this attribute is normalised into the mapping form. To disable filtering use either ``del pick.from_`` or ``pick.from_ = None``. .. code-block:: python import vtkplotlib as vpl import numpy as np fig = vpl.figure() ball = vpl.scatter([0, 0, 0], radius=10, color="green") text = vpl.text("Click on the green ball", color="black") # Create a restricted pick that will only treat clicks on anything # other than the ball as it would with clicks on the background. pick = vpl.i.pick(fig, from_=[ball]) def callback(pick): # We're going to use OnClick() instead of fig.style.AddObserver() # so this callback should only take a `pick` argument. if pick.actor is not None: vpl.scatter(pick.point, color="r", fig=fig) text.text = "Now try to click on one of the red balls" # OnClick is more suitable for capturing mouse clicks because it can # differentiate between a click and a click-and-drag. See the reference # for OnClick(). vpl.i.OnClick("Left", fig, callback, pick=pick) fig.show() If its not immediately clear what the difference is then remove the ``from_=[ball]`` try clicking on a red ball a few times, then rotate the camera slightly. You should see that each click creates a new red ball on top of the last, creating a tower of balls. """ return self._from_map @from_.setter def from_(self, from_): if from_ is None: del self.from_ else: if not isinstance(from_, Mapping): from_ = {getattr(i, "actor", i): i for i in from_} self.picker.GetPickList().RemoveAllItems() _actor_collection(from_, self.picker.GetPickList()) self._from_map = from_ self.picker.PickFromListOn() @from_.deleter def from_(self): self._from_map = None self.picker.GetPickList().RemoveAllItems() self.picker.PickFromListOff() @property def picked(self): """Contains the plot where the event happened. This can be thought of as equivalent to ``pick.from_[pick.actor]``. If the `from_` has not been set or the event happened over empty space or over an actor which isn't in `pick.from_` then the output is `None`. .. code-block:: python import vtkplotlib as vpl import numpy as np fig = vpl.figure() spheres = vpl.scatter(np.random.uniform(-30, 30, (50, 3))) vpl.text("Click on the spheres") def callback(pick): sphere = pick.picked if sphere is not None: sphere.color = np.random.random(3) vpl.i.OnClick("Left", fig, callback, pick=vpl.i.pick(fig, from_=spheres)) fig.show() """ if (self.actor is not None) and (self._from_map is not None): return self._from_map[self.actor] @_requires_active_iren() def update(self, iren): self.point_2D = iren.GetEventPosition() return self @property def point_2D(self): """The 2D ``(horizontal, vertical)`` coordinates in pixels where the event happened. ``(0, 0)`` is the left lower corner of the window. """ # For some strange reason GetSelectionPoint() includes a 3rd dimension # which is always zero. Get rid of it as it's confusing. return self.picker.GetSelectionPoint()[:2] @point_2D.setter def point_2D(self, point): if len(point) == 2: self.picker.Pick(point[0], point[1], 0, self.style.GetCurrentRenderer()) else: self.picker.Pick(point[0], point[1], self.style.GetCurrentRenderer()) @property def point(self): """A 3D coordinates tuple of where the event took place. If the event happened over empty background or a 2D plot such as a `vtkplotlib.scalar_bar()` then outputs a 3-tuple of nans. To check for this use ``pick.actor_3D is None``. The coordinates interpolate between vertices you have provided. If you require the nearest user-provided coordinate then you must implement this yourself. See :ref:`lookup_example:Looking up original data`. """ if self.actor_3D is not None: return self.picker.GetPickPosition() else: return (np.nan, np.nan, np.nan) @property def actor(self): """The `vtkActor`_ of the plot where the event took place. This corresponds to ``plot.actor`` where ``plot`` is the output of any `vtkplotlib` plotting function. .. code-block:: python import vtkplotlib as vpl import numpy as np fig = vpl.figure() spheres = vpl.scatter(np.random.uniform(-10, 10, (30, 3))) vpl.text("Hover the mouse over a sphere") def callback(invoker, event_name): actor = vpl.i.pick(invoker).actor for sphere in spheres: if sphere.actor is actor: sphere.color = "blue" else: sphere.color = "white" vpl.i.call_super_callback() fig.update() fig.style.AddObserver("MouseMoveEvent", callback) fig.show() """ return self.picker.GetActor() @property def actor_2D(self): return self.picker.GetActor2D() @property def actor_3D(self): return self.picker.GetProp3D() @property def view_prop(self): return self.picker.GetViewProp() @property def volume(self): return self.picker.GetVolume() @property @_requires_active_iren("") def key_text(self, iren): """`key_text` is used to capture keyboard interaction. See `key_name` for more information.""" try: return iren.GetKeyCode() except UnicodeError: return "" @property @_requires_active_iren("") def key_name(self, iren): """`key_name` is used to capture keyboard interaction. .. code-block:: python import vtkplotlib as vpl fig = vpl.figure() vpl.quick_test_plot() # The repr tells you everything there is to know... callback = lambda *spam: print(vpl.i.pick(fig)) # Attach to either 'KeyPressEvent' or 'KeyReleaseEvent'. fig.style.AddObserver("KeyPressEvent", callback) fig.show() There is also a similar `key_text` attribute. The `key_text` is usually the single character typed when a given key is pressed. If pressing that key doesn't normally type anything (e.g. pressing **shift**) then `key_text` is empty. `key_name` is always defined. The following table shows the differences between the two. ================ ================ ========== Action `key_name` `key_text` ================ ================ ========== Press 'a' key ``'a'`` ``'a'`` Press Shift a ``'A'`` ``'A'`` Press Shift key ``'Shift_L'`` ``''`` Press Return key ``'return'`` ``'\\r'`` Press F5 key ``'F5'`` ``''`` Press '#' key ``'numbersign'`` ``'#'`` Press '?' key ``'question'`` ``'?'`` Type unicode Á ``'a'`` ``''`` ================ ================ ========== .. note:: Unlike with every other event, there is no need to use :func:`call_super_callback()`. It is called implicitly. """ return iren.GetKeySym() _KEY_MODIFIERS = [(i, "Get%sKey" % i) for i in ("Shift", "Control", "Alt")] _KEY_MODIFIERS = [(i, getattr(vtk.vtkRenderWindowInteractor, j)) for (i, j) in _KEY_MODIFIERS if hasattr(vtk.vtkRenderWindowInteractor, j)] @property @_requires_active_iren(()) def key_modifiers(self, iren): """`key_modifiers` lists currently held down modifiers keys. The result can be any combination of ``("Shift", "Control", "Alt")``. The order is consistent, meaning that it is safe to use something like the following: .. code-block:: python if pick.key_modifiers == ("Shift", "Alt"): There is not *Super* key in VTK. """ return tuple(key for (key, get) in self._KEY_MODIFIERS if get(iren)) def __repr__(self): out = type(self).__name__ + " {\n" for key in self.KEYS: if key == "from_": value = ("NULL - pick.from_ is not set" if self.from_ is None else "%i items" % len(self.from_)) else: value = _mini_vtk_repr(getattr(self, key)) out += " %s: %s\n" % (key, value) return out + "}\n" KEYS = sorted( key for (key, val) in locals().items() if isinstance(val, property))
def _mini_vtk_repr(obj): """The ``__str__`` method of `vtkObject`_ and its descendants tells you everything about it, often recursing to child objects, making it very long. This is helpful sometimes but not always. Use Python's default ``__str__`` in cases where every detail is not desired.""" if isinstance(obj, vtk.vtkObject): return object.__repr__(obj) return repr(obj) # Get all the supported mouse button types (e.g. Left, Right, Middle, ...) by # iterating through `dir(vtkCommands)`. Note that `re.fullmatch()` doesn't # exist in python 2 - hence the "\A...\Z" in the regex. _mouse_buttons = set( i.group(1) for i in map(_re.compile(r"\A(\w+)ButtonPressEvent\Z").match, vtkCommands) if i is not None ) # yapf: disable
[docs]class OnClick(object): VALID_BUTTONS = _mouse_buttons # language=rst __doc__ = \ """:class:`OnClick` provides a higher-level means to attach callbacks to mouse click events without unintentionally also catching mouse click-and-drag events. :param button: Any of *{}* (case-insensitive). :type button: str :param style: The figure or `vtkInteractorStyle`_ to attach to, writeable. :type style: `vtkplotlib.figure`, `vtkInteractorStyle`_ :param on_click: Method to call when a click happens, writeable, set to None to disable, defaults to :func:`print`. :type on_click: callable taking a :class:`pick` as an argument, None :param mouse_shift_tolerance: The maximum mouse movement in pixels between mouse-down and mouse-up allowed for a click to not be counted as a click-and-drag, writeable, defaults to ``2``. :type mouse_shift_tolerance: int :param pick: Set a custom :meth:`pick`, writeable, defaults to creating its own on initialisation. :type pick: pick All parameters are available as attributes with the same name. Of these, parameters labelled *writeable* can be set or altered later. .. note:: *Forth* and *Fifth* **button** names require ``VTK>=8.0``. Usage of :meth:`OnClick` differs from that of ``fig.style.AddObserver(event_name, callback)`` in the following ways. * Callbacks take a single argument **pick**. * The calling of `call_super_callback` is automatic. * If using a user-supplied **pick**, then ``pick.update()`` is called automatically before passing it to the callback. See the example from `pick.from\\_ <pick.html#vtkplotlib.interactive.pick.from\\_>`_ for a usage demonstration. """.format("*, *".join(VALID_BUTTONS)) def __init__(self, button, style, on_click=print, mouse_shift_tolerance=2, pick=None): button = button.capitalize() assert button in self.VALID_BUTTONS self.button = button style = self.style = getattr(style, "style", style) self.mouse_shift_tolerance = mouse_shift_tolerance self._click_location = None self.on_click = on_click self.pick = pick or globals()["pick"](self.style) # Only call style.OnMouseMove() if another callback isn't already # doing it. This isn't an ideal work around. self._super_on_mouse_move = not style.HasObserver("MouseMoveEvent") style.AddObserver(self.button + "ButtonPressEvent", self._press_cb) style.AddObserver(self.button + "ButtonReleaseEvent", self._release_cb) style.AddObserver("MouseMoveEvent", self._mouse_move_cb) def _press_cb(self, invoker, name): call_super_callback() self.pick.update() self._click_location = self.pick.point_2D def _clicks_are_equal(self, point_0, point_1): shift_sqr = sum((i - j)**2 for (i, j) in zip(point_0, point_1)) return shift_sqr <= self.mouse_shift_tolerance**2 def _release_cb(self, invoker, name): if (self._click_location is not None and self.pick.update().actor is not None and self._clicks_are_equal(self._click_location, self.pick.point_2D) and self.on_click is not None): self.on_click(self.pick) call_super_callback() def _mouse_move_cb(self, invoker, name): if self._click_location: self.pick.update() if self._clicks_are_equal(self._click_location, self.pick.point_2D): return self._click_location = None # Only calling the super event with the mouse button down (which rotates # the model for left click) when we are sure that this click is not # meant to place a marker reduces the slight jolt when you click on with # a sensitive mouse. Move the lines below to the top of this method to # see what I mean. if self._super_on_mouse_move: call_super_callback()
if __name__ == "__main__": import vtkplotlib as vpl fig = vpl.QtFigure2() style = fig.style balls = vpl.quick_test_plot() rabbit = vpl.mesh_plot(vpl.data.get_rabbit_stl()) rabbit.vertices -= [i.mean() for i in vpl.unzip_axes(rabbit.vertices)] rabbit.vertices /= 5 text = vpl.text("text") vpl.show()