Source code for fastplotlib.graphics._base

from collections import defaultdict
from functools import partial
from typing import Any, Literal, TypeAlias
import weakref

import numpy as np
import pylinalg as la
from wgpu.gui.base import log_exception

try:
    from imgui_bundle import imgui
except ImportError:
    IMGUI = False
else:
    IMGUI = True

import pygfx

from .features import (
    BufferManager,
    Deleted,
    Name,
    Offset,
    Rotation,
    Visible,
)
from ._axes import Axes

HexStr: TypeAlias = str

# dict that holds all world objects for a given python kernel/session
# Graphic objects only use proxies to WorldObjects
WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict()  #: {hex id str: WorldObject}


PYGFX_EVENTS = [
    "key_down",
    "key_up",
    "pointer_down",
    "pointer_move",
    "pointer_up",
    "pointer_enter",
    "pointer_leave",
    "click",
    "double_click",
    "wheel",
    "close",
    "resize",
]


[docs] class Graphic: _features: dict[str, type] = dict() def __init_subclass__(cls, **kwargs): # set the type of the graphic in lower case like "image", "line_collection", etc. cls.type = ( cls.__name__.lower() .replace("graphic", "") .replace("collection", "_collection") .replace("stack", "_stack") ) # set of all features cls._features = { **cls._features, "name": Name, "offset": Offset, "rotation": Rotation, "visible": Visible, "deleted": Deleted, } super().__init_subclass__(**kwargs) def __init__( self, name: str = None, offset: np.ndarray | list | tuple = (0.0, 0.0, 0.0), rotation: np.ndarray | list | tuple = (0.0, 0.0, 0.0, 1.0), visible: bool = True, metadata: Any = None, ): """ Parameters ---------- name: str, optional name this graphic to use it as a key to access from the plot offset: (float, float, float), default (0., 0., 0.) (x, y, z) vector to offset this graphic from the origin rotation: (float, float, float, float), default (0, 0, 0, 1) rotation quaternion metadata: Any, optional metadata attached to this Graphic, this is for the user to manage """ if (name is not None) and (not isinstance(name, str)): raise TypeError("Graphic `name` must be of type <str>") self.metadata = metadata self.registered_callbacks = dict() # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) self._plot_area = None # event handlers self._event_handlers = defaultdict(set) # maps callbacks to their partials self._event_handler_wrappers = defaultdict(set) # all the common features self._name = Name(name) self._deleted = Deleted(False) self._rotation = Rotation(rotation) self._offset = Offset(offset) self._visible = Visible(visible) self._block_events = False self._axes: Axes = None self._right_click_menu = None @property def supported_events(self) -> tuple[str]: """events supported by this graphic""" return (*tuple(self._features.keys()), *PYGFX_EVENTS) @property def name(self) -> str | None: """Graphic name""" return self._name.value @name.setter def name(self, value: str): self._name.set_value(self, value) @property def offset(self) -> np.ndarray: """Offset position of the graphic, array: [x, y, z]""" return self._offset.value @offset.setter def offset(self, value: np.ndarray | list | tuple): self._offset.set_value(self, value) @property def rotation(self) -> np.ndarray: """Orientation of the graphic as a quaternion""" return self._rotation.value @rotation.setter def rotation(self, value: np.ndarray | list | tuple): self._rotation.set_value(self, value) @property def visible(self) -> bool: """Whether the graphic is visible""" return self._visible.value @visible.setter def visible(self, value: bool): self._visible.set_value(self, value) @property def deleted(self) -> bool: """used to emit an event after the graphic is deleted""" return self._deleted.value @deleted.setter def deleted(self, value: bool): self._deleted.set_value(self, value) @property def block_events(self) -> bool: """Used to block events for a graphic and prevent recursion.""" return self._block_events @block_events.setter def block_events(self, value: bool): self._block_events = value @property def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" # We use weakref to simplify garbage collection return weakref.proxy(WORLD_OBJECTS[hex(id(self))]) def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo self.world_object.visible = self.visible # set offset if it's not (0., 0., 0.) if not all(self.world_object.world.position == self.offset): self.offset = self.offset # set rotation if it's not (0., 0., 0., 1.) if not all(self.world_object.world.rotation == self.rotation): self.rotation = self.rotation
[docs] def unshare_property(self, feature: str): raise NotImplementedError
[docs] def share_property(self, feature: BufferManager): raise NotImplementedError
@property def event_handlers(self) -> list[tuple[str, callable, ...]]: """ Registered event handlers. Read-only use ``add_event_handler()`` and ``remove_event_handler()`` to manage callbacks """ return list(self._event_handlers.items())
[docs] def add_event_handler(self, *args): """ Register an event handler. Can also be used as a decorator. Parameters ---------- callback: callable, the first argument Event handler, must accept a single event argument *types: strings event types, ex: "click", "data", "colors", "pointer_down" ``supported_events`` will return a tuple of all event type strings that this graphic supports. See the user guide in the documentation for more information on events. Example ------- .. code-block:: py def my_handler(event): print(event) graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") Decorator usage example: .. code-block:: py @graphic.add_event_handler("click") def my_handler(event): print(event) """ decorating = not callable(args[0]) callback = None if decorating else args[0] types = args if decorating else args[1:] unsupported_events = [t for t in types if t not in self.supported_events] if len(unsupported_events) > 0: raise TypeError( f"unsupported events passed: {unsupported_events} for {self.__class__.__name__}\n" f"`graphic.events` will return a tuple of supported events" ) def decorator(_callback): _callback_wrapper = partial( self._handle_event, _callback ) # adds graphic instance as attribute and other things for t in types: # add to our record self._event_handlers[t].add(_callback) if t in self._features.keys(): # fpl feature event feature = getattr(self, f"_{t}") feature.add_event_handler(_callback_wrapper) else: # wrap pygfx event self.world_object._event_handlers[t].add(_callback_wrapper) # keep track of the partial too self._event_handler_wrappers[t].add((_callback, _callback_wrapper)) return _callback if decorating: return decorator return decorator(callback)
[docs] def clear_event_handlers(self): """clear all event handlers added to this graphic""" for ev, handlers in self.event_handlers: handlers = list(handlers) for h in handlers: self.remove_event_handler(h, ev)
def _handle_event(self, callback, event: pygfx.Event): """Wrap pygfx event to add graphic to pick_info""" event.graphic = self if self.block_events: return if event.type in self._features: # for feature events event._target = self.world_object with log_exception(f"Error during handling {event.type} event"): callback(event)
[docs] def remove_event_handler(self, callback, *types): """ remove a registered event handler Parameters ---------- callback: callable event handler that has been added *types: strings event types that were registered with the given callback Example ------- .. code-block:: py # define event handler def my_handler(event): print(event) # add event handler graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") # remove event handler graphic.remove_event_handler(my_handler, "pointer_up", "pointer_down") """ # remove from our record first for t in types: for wrapper_map in self._event_handler_wrappers[t]: # TODO: not sure if we can handle this mapping in a better way if wrapper_map[0] == callback: wrapper = wrapper_map[1] self._event_handler_wrappers[t].remove(wrapper_map) break else: raise KeyError( f"event type: {t} with callback: {callback} is not registered" ) self._event_handlers[t].remove(callback) # remove callback wrapper from world object if pygfx event if t in PYGFX_EVENTS: self.world_object.remove_event_handler(wrapper, t) else: feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper)
def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area def __repr__(self): rval = f"{self.__class__.__name__}" if self.name is not None: return f"'{self.name}': {rval}" else: return rval def _fpl_prepare_del(self): """ Cleans up the graphic in preparation for __del__(), such as removing event handlers from plot renderer, feature event handlers, etc. Optionally implemented in subclasses """ # remove axes if added to this graphic if self._axes is not None: self._plot_area.scene.remove(self._axes) self._plot_area.remove_animation(self._update_axes) self._axes.world_object.clear() # signal that a deletion has been requested self.deleted = True # clear event handlers self.clear_event_handlers() # clear any attached event handlers and animation functions for attr in dir(self): try: method = getattr(self, attr) except: continue if not callable(method): continue for ev_type in PYGFX_EVENTS: try: self._plot_area.renderer.remove_event_handler(method, ev_type) except (KeyError, TypeError): pass try: self._plot_area.remove_animation(method) except KeyError: pass for child in self.world_object.children: child._event_handlers.clear() self.world_object._event_handlers.clear() def __del__(self): # remove world object if created # world object does not exist if an exception was raised during __init__ which is why this check exists WORLD_OBJECTS.pop(hex(id(self)), None)
[docs] def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): """Rotate the Graphic with respect to the world. Parameters ---------- alpha : Rotation angle in radians. axis : Rotation axis label. """ if axis == "x": rot = la.quat_from_euler((alpha, 0), order="XY") elif axis == "y": rot = la.quat_from_euler((0, alpha), order="XY") elif axis == "z": rot = la.quat_from_euler((0, alpha), order="XZ") else: raise ValueError( f"`axis` must be either `x`, `y`, or `z`. `{axis}` provided instead!" ) self.rotation = la.quat_mul(rot, self.rotation)
@property def axes(self) -> Axes: return self._axes
[docs] def add_axes(self): """Add axes onto this Graphic""" if self._axes is not None: raise AttributeError("Axes already added onto this graphic") self._axes = Axes(self._plot_area, offset=self.offset, grids=False) self._axes.world_object.local.rotation = self.world_object.local.rotation self._plot_area.scene.add(self.axes.world_object) self._axes.update_using_bbox(self.world_object.get_world_bounding_box())
@property def right_click_menu(self): return self._right_click_menu @right_click_menu.setter def right_click_menu(self, menu): if not IMGUI: raise ImportError( "imgui is required to set right-click menus:\n" "pip install imgui_bundle" ) self._right_click_menu = menu menu.owner = self def _fpl_request_right_click_menu(self): pass def _fpl_close_right_click_menu(self): pass