Source code for fastplotlib.graphics._base

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

import numpy as np
import pylinalg as la
from rendercanvas.base import log_exception

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

import pygfx

from .features import (
    GraphicFeature,
    Deleted,
    Name,
    Offset,
    Rotation,
    Scale,
    Alpha,
    AlphaMode,
    Visible,
)
from ..axes import Axes
from ..utils import global_config

HexStr: TypeAlias = str
WorldObjectID: TypeAlias = int

# 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}

# maps world object to the graphic which owns it, useful when manually picking from the renderer and we
# need to know the graphic associated with the target world object
WORLD_OBJECT_TO_GRAPHIC: dict[WorldObjectID, Graphic] = dict()


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: config = global_config.descriptor _features: dict[str, type[GraphicFeature] | tuple[type[GraphicFeature], ...]] = ( dict() ) # It also doesn't make sense to create tooltips for some graphics # ex: text, that would be very funny. # They would also get in the way of selector tools _fpl_support_tooltip: bool = True def __init_subclass__(cls, **kwargs): # set of all features cls._features = { **cls._features, "name": Name, "offset": Offset, "rotation": Rotation, "scale": Scale, "alpha": Alpha, "alpha_mode": AlphaMode, "visible": Visible, "deleted": Deleted, } super().__init_subclass__(**kwargs) def __init__( self, name: str = None, offset: np.ndarray | tuple[float] = (0.0, 0.0, 0.0), rotation: np.ndarray | tuple[float] = (0.0, 0.0, 0.0, 1.0), scale: np.ndarray | tuple[float] = (1.0, 1.0, 1.0), alpha: float = 1.0, alpha_mode: str = "auto", 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 scale: (float, float, float), default (1.0, 1.0, 1.0) (x, y, z) scale factors alpha: (float), default 1.0 The global alpha value, i.e. opacity, of the graphic. The alpha value for the colors. If you make your a graphic transparent, consider setting ``alpha_mode`` to 'blend' or 'weighted_blend' so it won't write to the depth buffer. alpha_mode: (str), default "auto", The alpha-mode, e.g. 'auto', 'blend', 'weighted_blend', 'solid', or 'dither'. Modes for method “opaque” (overwrites the value in the output texture): * “solid”: alpha is ignored. * “solid_premul”: the alpha is multipled with the color (making it darker). Modes for method “blended” (per-fragment blending, a.k.a. compositing): * “auto”: classic alpha blending, with depth_write defaulting to True. See note below. * “blend”: classic alpha blending using the over-operator. depth_write defaults to False. * “add”: additive blending that adds the fragment color, multiplied by alpha. * “subtract”: subtractuve blending that removes the fragment color. * “multiply”: multiplicative blending that multiplies the fragment color. Modes for method “weighted” (order independent blending): * “weighted_blend”: weighted blended order independent transparency. * “weighted_solid”: fragments are combined based on alpha, but the final alpha is always 1. Great for e.g. image stitching. Modes for method “stochastic” (alpha represents the chance of a fragment being visible): * “dither”: stochastic transparency with blue noise. This mode handles order-independent transparency exceptionally well, but it produces results that can look somewhat noisy. * “bayer”: stochastic transparency with an 8x8 Bayer pattern. For details see https://docs.pygfx.org/stable/transparency.html visible: (bool), default True Whether the graphic is visible. 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._scale = Scale(scale) self._offset = Offset(offset) self._alpha = Alpha(alpha) self._alpha_mode = AlphaMode(alpha_mode) self._visible = Visible(visible) self._block_events = False self._block_handlers = list() self._axes: Axes = None self._imgui_right_click = None self._legend_item = None # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() self._tooltip_format: Callable = 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 | tuple[float, float, float]): 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 | tuple[float, float, float, float]): self._rotation.set_value(self, value) @property def scale(self) -> np.ndarray: """(x, y, z) scaling factor""" return self._scale.value @scale.setter def scale(self, value: np.ndarray | tuple[float, float, float]): self._scale.set_value(self, value) @property def alpha(self) -> float: """The opacity of the graphic""" return self._alpha.value @alpha.setter def alpha(self, value: float): self._alpha.set_value(self, value) @property def alpha_mode(self) -> str: """How the alpha is handled by the renderer""" return self._alpha_mode.value @alpha_mode.setter def alpha_mode(self, value: str): self._alpha_mode.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 block_handlers(self) -> list: """Used to block event handlers for a graphic and prevent recursion.""" return self._block_handlers @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 # add to world object -> graphic mapping if isinstance(wo, pygfx.Group): # for Graphics which use a pygfx.Group, ImageGraphic and graphic collections self._add_group_graphic_map(wo) else: global_id = wo.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self # store id to pop from dict when graphic is deleted self._world_object_ids.append(global_id) wo.visible = self.visible # exclude collections, whose class name also contains "Image" if "Image" in self.__class__.__name__ and not hasattr(self, "graphics"): # Image and ImageVolume use tiling and share one material self._material.opacity = self.alpha self._material.alpha_mode = self.alpha_mode if wo.material is not None: wo.material.opacity = self.alpha wo.material.alpha_mode = self.alpha_mode # set offset if it's not (0., 0., 0.) if not all(wo.world.position == self.offset): self.offset = self.offset # set rotation if it's not (0., 0., 0., 1.) if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation # set scale if it's not (1, 1, 1) if not all(wo.world.scale == self.scale): self.scale = self.scale def _add_group_graphic_map(self, wo: pygfx.Group): # add the children of the group to the WorldObject -> Graphic map # used by images since they create new WorldObject ImageTiles when a different buffer size is required # also used by GraphicCollections inititally, but not used for reseting like images for child in wo.children: if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): # unique 32 bit integer id for each world object global_id = child.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self # store id to pop from dict when graphic is deleted self._world_object_ids.append(global_id) def _remove_group_graphic_map(self, wo: pygfx.Group): # remove the children of the group to the WorldObject -> Graphic map for child in wo.children: if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): # unique 32 bit integer id for each world object global_id = child.id WORLD_OBJECT_TO_GRAPHIC.pop(global_id) self._world_object_ids.remove(global_id) @property def tooltip_format(self) -> Callable[[dict], str] | None: """ set a custom tooltip format function which takes a ``pick_info`` dict and returns a str to be displayed in the tooltip """ return self._tooltip_format @tooltip_format.setter def tooltip_format(self, func: Callable[[dict], str] | None): if func is None: self._tooltip_format = None return if not callable(func): raise TypeError( f"`tooltip_format` must be set with a callable that takes a pick_info dict, or it can be set as None" ) self._tooltip_format = func @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}") if feature is None: # feature is None in the graphic's current mode, probably is a scatter graphic raise AttributeError( f"{self} does not have the passed feature: '{t}' in its current mode." ) 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 callback in self._block_handlers: 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) continue feature = getattr(self, f"_{t}") if feature is None: # the feature was replaced when the graphic switched mode, e.g. colors when a # cmap is set, which cleared the handlers of the feature it replaced continue feature.remove_event_handler(wrapper)
def _parse_positions(self, position: tuple | np.ndarray) -> np.ndarray: """ Converts position data (in the form of tuple or np.ndarray) into a (num_points, 3)-shaped np.ndarray for processing """ position = np.asarray(position) if position.ndim not in (1, 2): raise ValueError(f"position must be of shape (num_points, 3) or (3,)") if position.ndim == 1: position = position[None, :] if position.shape[-1] != 3: raise ValueError(f"position must be of shape (num_points, 3) or (3,)") return position
[docs] def map_model_to_world( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: """ map position from model (data) space to world space, basically applies the world affine transform Parameters ---------- position: tuple[float, float, float] | np.ndarray The xyz positions we wish to map to world space, an (x, y, z) tuple or an array of shape (num_points, 3) Returns ------- np.ndarray either shape (3,) or (num_points, 3), specifying position in world space """ position = self._parse_positions(position) # apply world transform to project from model space to world space return la.vec_transform(position, self.world_object.world.matrix).squeeze()
[docs] def map_world_to_model( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: """ map position from world space to model (data) space, basically applies the inverse world affine transform Parameters ---------- position: tuple[float, float, float] | np.ndarray The xyz positions we wish to map to model space, an (x, y, z) tuple or an array of shape (num_points, 3) Returns ------- np.ndarray either shape (3,) or (num_points, 3), specifying position in model space """ position = self._parse_positions(position) return la.vec_transform( position, self.world_object.world.inverse_matrix ).squeeze()
[docs] def format_pick_info(self, ev: pygfx.PointerEvent) -> str: """ Takes a pygfx.PointerEvent and returns formatted pick info. """ raise NotImplementedError("must be implemented in subclass")
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 from world_obj -> graphic map for global_id in self._world_object_ids: WORLD_OBJECT_TO_GRAPHIC.pop(global_id) # 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 legend_item(self): """ The legend item of this graphic, created with ``create_legend_item()``. Returns ------- LegendItem | None """ return self._legend_item def _check_legend_item(self): """raise if a legend item cannot be created for this graphic, used by subclasses""" if not IMGUI: raise ImportError( "imgui is required to create legend items:\npip install imgui_bundle" ) if self._legend_item is not None: raise AttributeError("legend item already created for this graphic") @property def imgui_right_click(self): """ The imgui popup that is opened by a right-click on this graphic. Returns ------- ImguiPopup | None """ return self._imgui_right_click
[docs] def set_imgui_right_click(self, popup=None, *, window_flags=None): """ Set the imgui popup that is opened by a right-click on this graphic, replaces the popup of the subplot or Figure for this graphic. Can also be used as a decorator, see the ``ImguiFigure.set_imgui_right_click`` examples. Parameters ---------- popup: ImguiPopup | callable, optional an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. window_flags: ``imgui.WindowFlags_``, optional imgui window flags for the popup """ if not IMGUI: raise ImportError( "imgui is required to set right-click popups:\npip install imgui_bundle" ) from ..layouts._subplot import Subplot from ..ui._base import ImguiPopup, _wrap_update_call if not isinstance(self._plot_area, Subplot): raise TypeError( "graphic must be added to a subplot before setting an imgui right-click popup on it" ) figure = self._plot_area.get_figure() if "Imgui" not in figure.__class__.__name__: raise TypeError( "imgui right-click popups can only be set on a graphic in an ImguiFigure" ) def decorator(_popup): if isinstance(_popup, ImguiPopup): p = _popup elif callable(_popup): p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) else: raise TypeError( "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " "function that draws imgui elements" ) p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) self._imgui_right_click = p return _popup if popup is None: return decorator decorator(popup) return popup
[docs] def append_imgui_right_click(self, gui=None): """ Append imgui elements to the right-click popup of this graphic. Can also be used as a decorator. Parameters ---------- gui: callable, optional function that draws imgui elements, omit when decorating """ from ..ui._base import _wrap_update_call popup = self._imgui_right_click if popup is None: raise ValueError( "no imgui right-click popup set on this graphic to append to, set one using " "`graphic.set_imgui_right_click()`" ) def decorator(_gui): popup._update_calls.append(_wrap_update_call(_gui, self)) return _gui if gui is None: return decorator return decorator(gui)
[docs] def remove_imgui_right_click(self): """ Remove and return the right-click popup of this graphic Returns ------- ImguiPopup the removed popup, it can be set again later """ popup = self._imgui_right_click self._imgui_right_click = None return popup