from warnings import warn
from typing import Literal
import numpy as np
from numpy.typing import NDArray
from wgpu.gui.base import log_exception
import pygfx
def to_gpu_supported_dtype(array):
"""
convert input array to float32 numpy array
"""
if isinstance(array, np.ndarray):
if not array.dtype == np.float32:
warn(f"casting {array.dtype} array to float32")
return array.astype(np.float32)
return array
# try to make a numpy array from it, should not copy, tested with jax arrays
return np.asarray(array).astype(np.float32)
[docs]
class GraphicFeatureEvent(pygfx.Event):
"""
**All event instances have the following attributes**
+------------+-------------+-----------------------------------------------+
| attribute | type | description |
+============+=============+===============================================+
| type | str | "colors" - name of the event |
+------------+-------------+-----------------------------------------------+
| graphic | Graphic | graphic instance that the event is from |
+------------+-------------+-----------------------------------------------+
| info | dict | event info dictionary |
+------------+-------------+-----------------------------------------------+
| target | WorldObject | pygfx rendering engine object for the graphic |
+------------+-------------+-----------------------------------------------+
| time_stamp | float | time when the event occurred, in ms |
+------------+-------------+-----------------------------------------------+
"""
def __init__(self, type: str, info: dict):
super().__init__(type=type)
self.info = info
class GraphicFeature:
def __init__(self, **kwargs):
self._event_handlers = list()
self._block_events = False
# used by @block_reentrance decorator to block re-entrance into set_value functions
self._reentrant_block: bool = False
@property
def value(self):
"""Graphic Feature value, must be implemented in subclass"""
raise NotImplemented
def set_value(self, graphic, value: float):
"""Graphic Feature value setter, must be implemented in subclass"""
raise NotImplementedError
def block_events(self, val: bool):
"""
Block all events from this feature
Parameters
----------
val: bool
``True`` or ``False``
"""
self._block_events = val
def add_event_handler(self, handler: callable):
"""
Add an event handler. All added event handlers are called when this feature changes.
Used by `Graphic` classes to add to their event handlers, not meant for users. Users
add handlers to Graphic instances only.
The ``handler`` must accept a :class:`.FeatureEvent` as the first and only argument.
Parameters
----------
handler: callable
a function to call when this feature changes
"""
if not callable(handler):
raise TypeError("event handler must be callable")
if handler in self._event_handlers:
warn(f"Event handler {handler} is already registered.")
return
self._event_handlers.append(handler)
def remove_event_handler(self, handler: callable):
"""
Remove a registered event ``handler``.
Parameters
----------
handler: callable
event handler to remove
"""
if handler not in self._event_handlers:
raise KeyError(f"event handler {handler} not registered.")
self._event_handlers.remove(handler)
def clear_event_handlers(self):
"""Clear all event handlers"""
self._event_handlers.clear()
def _call_event_handlers(self, event_data: GraphicFeatureEvent):
if self._block_events:
return
for func in self._event_handlers:
with log_exception(
f"Error during handling {self.__class__.__name__} event"
):
func(event_data)
class BufferManager(GraphicFeature):
"""Smaller wrapper for pygfx.Buffer"""
def __init__(
self,
data: NDArray | pygfx.Buffer,
buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer",
isolated_buffer: bool = True,
texture_dim: int = 2,
**kwargs,
):
super().__init__()
if isolated_buffer and not isinstance(data, pygfx.Resource):
# useful if data is read-only, example: memmaps
bdata = np.zeros(data.shape, dtype=data.dtype)
bdata[:] = data[:]
else:
# user's input array is used as the buffer
bdata = data
if isinstance(data, pygfx.Resource):
# already a buffer, probably used for
# managing another BufferManager, example: VertexCmap manages VertexColors
self._buffer = data
elif buffer_type == "buffer":
self._buffer = pygfx.Buffer(bdata)
elif buffer_type == "texture":
# TODO: placeholder, not currently used since TextureArray is used specifically for Image graphics
self._buffer = pygfx.Texture(bdata, dim=texture_dim)
else:
raise ValueError(
"`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'"
)
self._event_handlers: list[callable] = list()
self._shared: int = 0
@property
def value(self) -> np.ndarray:
"""numpy array object representing the data managed by this buffer"""
return self.buffer.data
def set_value(self, graphic, value):
"""Sets values on entire array"""
self[:] = value
@property
def buffer(self) -> pygfx.Buffer | pygfx.Texture:
"""managed buffer"""
return self._buffer
@property
def shared(self) -> int:
"""Number of graphics that share this buffer"""
return self._shared
@property
def __array_interface__(self):
raise BufferError(
f"Cannot use graphic feature buffer as an array, use <feature-name>.value instead.\n"
f"Examples: line.data.value, line.colors.value, scatter.data.value, scatter.sizes.value"
)
def __getitem__(self, item):
return self.buffer.data[item]
def __setitem__(self, key, value):
raise NotImplementedError
def _parse_offset_size(
self,
key: int | slice | np.ndarray[int | bool] | list[bool | int],
upper_bound: int,
):
"""
parse offset and size for first, i.e. n_datapoints, dimension
"""
if np.issubdtype(type(key), np.integer):
# simplest case, just an int
offset = key
size = 1
elif isinstance(key, slice):
# TODO: off-by-one sometimes when step is used
# the offset can be one to the left or the size
# is one extra so it's not really an issue for now
# parse slice
start, stop, step = key.indices(upper_bound)
# account for backwards indexing
if (start > stop) and step < 0:
offset = stop
else:
offset = start
# slice.indices will give -1 if None is passed
# which just means 0 here since buffers do not
# use negative indexing
offset = max(0, offset)
# number of elements to upload
# this is indexing so do not add 1
size = abs(stop - start)
elif isinstance(key, (np.ndarray, list)):
if isinstance(key, list):
# convert to array
key = np.array(key)
if not key.ndim == 1:
raise TypeError(
f"can only use 1D arrays for fancy indexing, you have passed a data with: {key.ndim} dimensions"
)
if key.dtype == bool:
# convert bool mask to integer indices
key = np.nonzero(key)[0]
if not np.issubdtype(key.dtype, np.integer):
# fancy indexing doesn't make sense with non-integer types
raise TypeError(
f"can only using integer or booleans arrays for fancy indexing, your array is of type: {key.dtype}"
)
if key.size < 1:
# nothing to update
return
# convert any negative integer indices to positive indices
key %= upper_bound
# index of first element to upload
offset = key.min()
# size range to upload
# add 1 because this is direct
# passing of indices, not a start:stop
size = np.ptp(key) + 1
else:
raise TypeError(
f"invalid key for indexing buffer: {key}\n"
f"valid ways to index buffers are using integers, slices, or fancy indexing with integers or bool"
)
return offset, size
def _update_range(
self,
key: (
int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]
),
):
"""
Uses key from slicing to determine the offset and
size of the buffer to mark for upload to the GPU
"""
upper_bound = self.value.shape[0]
if isinstance(key, tuple):
if any([k is Ellipsis for k in key]):
# let's worry about ellipsis later
raise TypeError("ellipses not supported for indexing buffers")
# if multiple dims are sliced, we only need the key for
# the first dimension corresponding to n_datapoints
key: int | np.ndarray[int | bool] | slice = key[0]
offset, size = self._parse_offset_size(key, upper_bound)
self.buffer.update_range(offset=offset, size=size)
def _emit_event(self, type: str, key, value):
if len(self._event_handlers) < 1:
return
event_info = {
"key": key,
"value": value,
}
event = GraphicFeatureEvent(type, info=event_info)
self._call_event_handlers(event)
def __len__(self):
raise NotImplementedError
def __repr__(self):
return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}"
def block_reentrance(set_value):
# decorator to block re-entrant set_value methods
# useful when creating complex, circular, bidirectional event graphs
def set_value_wrapper(self: GraphicFeature, graphic_or_key, value):
"""
wraps GraphicFeature.set_value
self: GraphicFeature instance
graphic_or_key: graphic, or key if a BufferManager
value: the value passed to set_value()
"""
# set_value is already in the middle of an execution, block re-entrance
if self._reentrant_block:
return
try:
# block re-execution of set_value until it has *fully* finished executing
self._reentrant_block = True
set_value(self, graphic_or_key, value)
except Exception as exc:
# raise original exception
raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant!
finally:
# set_value has finished executing, now allow future executions
self._reentrant_block = False
return set_value_wrapper