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 (
BufferManager,
Deleted,
Name,
Offset,
Rotation,
Scale,
Alpha,
AlphaMode,
Visible,
)
from ._axes import Axes
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:
_features: dict[str, type] = 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._axes: Axes = None
self._right_click_menu = 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 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 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)
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
if "Image" in self.__class__.__name__:
# 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
@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 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)
[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: (float, float, float) or (float, float)
(x, y, z) or (x, y) position. If z is not provided then the graphic's offset z is used.
Returns
-------
np.ndarray
(x, y, z) position in world space
"""
if len(position) == 2:
# use z of the graphic
position = [*position, self.offset[-1]]
if len(position) != 3:
raise ValueError(
f"position must be tuple or array indicating (x, y, z) position in *model space*"
)
# apply world transform to project from model space to world space
return la.vec_transform(position, self.world_object.world.matrix)
[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: (float, float, float) or (float, float)
(x, y, z) or (x, y) position. If z is not provided then 0 is used.
Returns
-------
np.ndarray
(x, y, z) position in world space
"""
if len(position) == 2:
# use z of the graphic
position = [*position, self.offset[-1]]
if len(position) != 3:
raise ValueError(
f"position must be tuple or array indicating (x, y, z) position in *model space*"
)
return la.vec_transform(position, self.world_object.world.inverse_matrix)
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 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:\npip 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