From 2828f48b562f6b7584e39b7336d1a8d8d5c33c51 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 Oct 2025 03:11:09 -0400 Subject: [PATCH 01/34] start vector fields --- fastplotlib/graphics/features/_vectors.py | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 fastplotlib/graphics/features/_vectors.py diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py new file mode 100644 index 000000000..94a12feee --- /dev/null +++ b/fastplotlib/graphics/features/_vectors.py @@ -0,0 +1,72 @@ +from typing import Sequence + +import numpy as np +import pygfx + +from ._base import ( + GraphicFeature, + BufferManager, + GraphicFeatureEvent, + block_reentrance, +) + +# it doesn't make sense to modify just a portion of a vector field, I can't think of a use case. +# so we only allow setting the entire buffer, but allow getting portions of it +# class VectorBuffer(BufferManager): +# """Manages the transform matrices for each mesh instance representing the vector""" +# def __init__(self, ): + + +class VectorPositions(GraphicFeature): + """Manages vector field positions by interfacing with VectorBuffer manager""" + def __init__(self, graphic, positions: np.ndarray | Sequence[float], isolated_buffer: bool = True, property_name: str = "positions"): + positions = np.asarray(positions, dtype=np.float32) + if positions.ndim != 2: + raise ValueError( + f"vector field positions must be of shape [n, 2] or [n, 3]" + ) + + if positions.shape[1] == 2: + positions = np.column_stack([positions[:, 0], positions[:, 1], np.zeros(positions.shape[0], dtype=np.float32)]) + + elif positions.shape[1] == 3: + pass + + else: + raise ValueError( + f"vector field positions must be of shape [n, 2] or [n, 3]" + ) + + self._positions = positions + self._graphic = graphic + + super().__init__(property_name) + + @property + def value(self) -> np.ndarray: + return self._positions + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + if value.shape[0] != self._positions.shape[0]: + raise ValueError( + f"number of vector positions in passed array != number of vectors in graphic: {value.shape[0]} != {self._positions.shape[0]}" + ) + + if value.shape[1] == 2: + # assume 2d + self._positions[:, :-1] = value + + else: + self._positions[:] = value + + for i in range(self._positions.shape[0]): + # only need to update the translation vector + graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = self._positions[i] + + graphic.world_object.instance_buffer.update_full() + + +class VectorDirections(GraphicFeature): + """Manager vector field directions by interfacing with VectorBuffer manager""" + pass \ No newline at end of file From c439fd9c4f38d2f7520da98ae19a03a93ae042ff Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 Oct 2025 05:30:24 -0400 Subject: [PATCH 02/34] basics of vector field works --- examples/vector_field/README.rst | 2 + examples/vector_field/vector_field_simple.py | 45 +++ examples/vector_field/vector_field_swirl.py | 49 +++ fastplotlib/graphics/__init__.py | 2 + fastplotlib/graphics/features/__init__.py | 8 + fastplotlib/graphics/features/_vectors.py | 129 +++++- fastplotlib/graphics/vector_field.py | 366 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 59 ++- 8 files changed, 644 insertions(+), 16 deletions(-) create mode 100644 examples/vector_field/README.rst create mode 100644 examples/vector_field/vector_field_simple.py create mode 100644 examples/vector_field/vector_field_swirl.py create mode 100644 fastplotlib/graphics/vector_field.py diff --git a/examples/vector_field/README.rst b/examples/vector_field/README.rst new file mode 100644 index 000000000..37f622377 --- /dev/null +++ b/examples/vector_field/README.rst @@ -0,0 +1,2 @@ +Vector Field Examples +===================== diff --git a/examples/vector_field/vector_field_simple.py b/examples/vector_field/vector_field_simple.py new file mode 100644 index 000000000..ba78c9395 --- /dev/null +++ b/examples/vector_field/vector_field_simple.py @@ -0,0 +1,45 @@ +""" +Simple Vector Field +=================== + +Simple vector field example. Similar to matplotlib quiver. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 700)) + +start, stop, step = 0, 2 * np.pi, 0.2 + +# get uniform x, y positions +x, y = np.meshgrid(np.arange(start, stop, step), np.arange(start, stop, step)) + +# vectors, u and v are x and y components indication directions +u = np.cos(x) +v = np.sin(y) + +# positions of each vector as [n_points, 2] array +positions = np.column_stack([x.ravel(), y.ravel()]) +# directions of each vector as a [n_points, 2] array +directions = np.column_stack([u.ravel(), v.ravel()]) + + +vector_field = figure[0, 0].add_vector_field( + positions=positions, + directions=directions, + spacing=step, + size_scaling_factor=5.0, +) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/vector_field/vector_field_swirl.py b/examples/vector_field/vector_field_swirl.py new file mode 100644 index 000000000..098e2fc20 --- /dev/null +++ b/examples/vector_field/vector_field_swirl.py @@ -0,0 +1,49 @@ +""" +Swirling vector field +===================== + +Example showing a swirling vector field. Similar to matplotlib quiver. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 700)) + +start, stop, step = -1, 1, 0.3 + +# Make the grid +x, y, z = np.meshgrid( + np.arange(start, stop, step), + np.arange(start, stop, step), + np.arange(start, stop, step), +) + +# Make the direction data for the arrows +u = np.sin(np.pi * x) * np.cos(np.pi * y) * np.cos(np.pi * z) +v = -np.cos(np.pi * x) * np.sin(np.pi * y) * np.cos(np.pi * z) +w = np.sqrt(2.0 / 3.0) * np.cos(np.pi * x) * np.cos(np.pi * y) * np.sin(np.pi * z) + +positions = np.column_stack([x.ravel(), y.ravel(), z.ravel()]) +directions = np.column_stack([u.ravel(), v.ravel(), w.ravel()]) + + +vector_field = figure[0, 0].add_vector_field( + positions=positions, + directions=directions, + spacing=step, + size_scaling_factor=2.0, +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index a3bbc1b5f..4576a3b9b 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -3,6 +3,7 @@ from .scatter import ScatterGraphic from .image import ImageGraphic from .image_volume import ImageVolumeGraphic +from .vector_field import VectorField from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -13,6 +14,7 @@ "ScatterGraphic", "ImageGraphic", "ImageVolumeGraphic", + "VectorField", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index eb834b674..172887d61 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -28,6 +28,12 @@ VOLUME_RENDER_MODES, create_volume_material_kwargs, ) + +from ._vectors import ( + VectorPositions, + VectorDirections, +) + from ._base import ( GraphicFeature, BufferManager, @@ -74,6 +80,8 @@ "VolumeIsoEmissive", "VolumeIsoShininess", "VolumeSlicePlane", + "VectorPositions", + "VectorDirections", "TextData", "FontSize", "TextFaceColor", diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 94a12feee..4aeef70eb 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -1,25 +1,26 @@ from typing import Sequence import numpy as np -import pygfx +import pylinalg as la from ._base import ( GraphicFeature, - BufferManager, GraphicFeatureEvent, block_reentrance, ) + # it doesn't make sense to modify just a portion of a vector field, I can't think of a use case. # so we only allow setting the entire buffer, but allow getting portions of it -# class VectorBuffer(BufferManager): -# """Manages the transform matrices for each mesh instance representing the vector""" -# def __init__(self, ): - - class VectorPositions(GraphicFeature): - """Manages vector field positions by interfacing with VectorBuffer manager""" - def __init__(self, graphic, positions: np.ndarray | Sequence[float], isolated_buffer: bool = True, property_name: str = "positions"): + """Manages vector field positions by managing the mesh instance buffer""" + + def __init__( + self, + positions: np.ndarray | Sequence[float], + isolated_buffer: bool = True, + property_name: str = "positions", + ): positions = np.asarray(positions, dtype=np.float32) if positions.ndim != 2: raise ValueError( @@ -27,7 +28,13 @@ def __init__(self, graphic, positions: np.ndarray | Sequence[float], isolated_bu ) if positions.shape[1] == 2: - positions = np.column_stack([positions[:, 0], positions[:, 1], np.zeros(positions.shape[0], dtype=np.float32)]) + positions = np.column_stack( + [ + positions[:, 0], + positions[:, 1], + np.zeros(positions.shape[0], dtype=np.float32), + ] + ) elif positions.shape[1] == 3: pass @@ -38,19 +45,27 @@ def __init__(self, graphic, positions: np.ndarray | Sequence[float], isolated_bu ) self._positions = positions - self._graphic = graphic - super().__init__(property_name) + super().__init__() @property def value(self) -> np.ndarray: return self._positions + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + raise NotImplementedError( + "cannot set individual slices of vector positions, must set all positions" + ) + @block_reentrance def set_value(self, graphic, value: np.ndarray): if value.shape[0] != self._positions.shape[0]: raise ValueError( - f"number of vector positions in passed array != number of vectors in graphic: {value.shape[0]} != {self._positions.shape[0]}" + f"number of vector positions in passed array != number of vectors in graphic: " + f"{value.shape[0]} != {self._positions.shape[0]}" ) if value.shape[1] == 2: @@ -62,11 +77,95 @@ def set_value(self, graphic, value: np.ndarray): for i in range(self._positions.shape[0]): # only need to update the translation vector - graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = self._positions[i] + graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = ( + self._positions[i] + ) graphic.world_object.instance_buffer.update_full() + event = GraphicFeatureEvent(type="positions", info={"value": value}) + self._call_event_handlers(event) + class VectorDirections(GraphicFeature): """Manager vector field directions by interfacing with VectorBuffer manager""" - pass \ No newline at end of file + + def __init__( + self, + directions: np.ndarray | Sequence[float], + isolated_buffer: bool = True, + property_name: str = "directions", + ): + directions = np.asarray(directions, dtype=np.float32) + if directions.ndim != 2: + raise ValueError( + f"vector field directions must be of shape [n, 2] or [n, 3]" + ) + + if directions.shape[1] == 2: + directions = np.column_stack( + [ + directions[:, 0], + directions[:, 1], + np.zeros(directions.shape[0], dtype=np.float32), + ] + ) + + elif directions.shape[1] == 3: + pass + + else: + raise ValueError( + f"vector field directions must be of shape [n, 2] or [n, 3]" + ) + + self._directions = directions + + super().__init__() + + @property + def value(self) -> np.ndarray: + return self._directions + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + raise NotImplementedError( + "cannot set individual slices of vector directions, must set all directions" + ) + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + if value.shape[0] != self._directions.shape[0]: + raise ValueError( + f"number of vector directions in passed array != number of vectors in graphic: " + f"{value.shape[0]} != {self._directions.shape[0]}" + ) + + old_directions = self._directions.copy() + + if value.shape[1] == 2: + # assume 2d + self._directions[:, :-1] = value + + else: + self._directions[:] = value + + # use the range of the 3D space to help set a scaling factor + range_3d = np.mean(np.ptp(graphic.positions[:], axis=0)) + # vector determines the size of the vector + magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) / range_3d + + for i in range(self._directions.shape[0]): + # get quaternion to rotate existing vector direction to new direction + rotation = la.quat_from_vecs(old_directions[i], self._directions[i]) + # get the new transform + transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i]) + # set the buffer + graphic.world_object.instance_buffer.data["matrix"][i] = transform.T + + graphic.world_object.instance_buffer.update_full() + + event = GraphicFeatureEvent(type="directions", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py new file mode 100644 index 000000000..24ffe1fd3 --- /dev/null +++ b/fastplotlib/graphics/vector_field.py @@ -0,0 +1,366 @@ +from typing import Sequence + +import pygfx +from pygfx.geometries.utils import merge as merge_geometries +import pylinalg as la +import numpy as np + +from ._base import Graphic +from .features import ( + VectorPositions, + VectorDirections, +) + + +class VectorField(Graphic): + _features = { + "positions": VectorPositions, + "directions": VectorDirections, + } + + def __init__( + self, + positions: np.ndarray | Sequence[float], + directions: np.ndarray | Sequence[float], + spacing: float, + color: str | Sequence[float] | np.ndarray = "w", + vector_shape_options: dict = None, + size_scaling_factor: float = 1.0, + **kwargs, + ): + """ + Create a Vector Field. Similar to matplotlib quiver. + + Parameters + ---------- + positions: np.ndarray | Sequence[float] + positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + directions: np.ndarray | Sequence[float] + directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + spacing: float + average distance between pairs of nearest-neighbor vectors, used for scaling + + color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" + color of the vectors + + vector_shape_options: dict + dict with the following fields that describe the shape of the vector arrows. + Larger values decrease the size of each component. + + * cone_radius_divisor, default 10.0 + * cone_height_divisor, default 4.0 + * stalk_radius_divisor, default 30.0 + * stalk_height_divisor, default 4.0 + + scaling_factor: float, default 1.0 + larger values will create larger vector arrows + + **kwargs + passed to :class:`.Graphic` + + """ + + super().__init__(**kwargs) + + self._positions = VectorPositions(positions) + self._directions = VectorDirections(directions) + + shape_options = dict( + cone_radius_divisor=10.0, + cone_height_divisor=4.0, + stalk_radius_divisor=30.0, + stalk_height_divisor=4.0, + ) + + if vector_shape_options is None: + vector_shape_options = {} + + for k in vector_shape_options: + if k not in shape_options: + raise KeyError( + f"valid dict fields for `vector_shape_options` are: {list(shape_options.keys())}. " + f"You passed the following dict: {vector_shape_options}" + ) + + shape_options = {**shape_options, **vector_shape_options} + + geometry = create_vector_geometry(spacing=spacing, color=color, **shape_options) + material = pygfx.MeshBasicMaterial() + n_vectors = self._positions.value.shape[0] + + world_object = pygfx.InstancedMesh(geometry, material, n_vectors) + + range_3d = np.mean(np.ptp(self._positions[:], axis=0)) + magnitudes = ( + np.linalg.norm(self.directions[:], axis=1, ord=2) / range_3d + ) * size_scaling_factor + + start_rot = np.array([0, 0, 1]) + + for i in range(n_vectors): + # get quaternion to rotate existing vector direction to new direction + rotation = la.quat_from_vecs(start_rot, self._directions[i]) + # get the new transform + transform = la.mat_compose( + self._positions.value[i], rotation, magnitudes[i] + ) + # set the buffer + world_object.instance_buffer.data["matrix"][i] = transform.T + + world_object.instance_buffer.update_full() + + self._set_world_object(world_object) + + @property + def positions(self) -> VectorPositions: + """Vector positions""" + return self._positions + + @positions.setter + def positions(self, new_positions): + self._positions.set_value(self, new_positions) + + @property + def directions(self) -> VectorDirections: + """Vector directions""" + return self._directions + + @directions.setter + def directions(self, new_directions): + self._directions.set_value(self, new_directions) + + +# mesh code copied and adapted from pygfx +def generate_torso( + radius_bottom, + radius_top, + height, + radial_segments, + height_segments, + theta_start, + theta_length, + z_offset=0.0, +): + # compute POSITIONS assuming x-y horizontal plane and z up axis + + # radius for each vertex ring from bottom to top + n_rings = height_segments + 1 + radii = np.linspace(radius_bottom, radius_top, num=n_rings, dtype=np.float32) + + # height for each vertex ring from bottom to top + half_height = height / 2 + heights = np.linspace(-half_height, half_height, num=n_rings, dtype=np.float32) + + # to enable texture mapping to fully wrap around the cylinder, + # we can't close the geometry and need a degenerate vertex + n_vertices = radial_segments + 1 + + # xy coordinates on unit circle for a single vertex ring + theta = np.linspace( + theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32 + ) + ring_xy = np.column_stack([np.cos(theta), np.sin(theta)]) + + # put all the rings together + positions = np.empty((n_rings, n_vertices, 3), dtype=np.float32) + positions[..., :2] = ring_xy[None, ...] * radii[:, None, None] + positions[..., 2] = heights[:, None] - z_offset + + # the NORMALS are the same for every ring, so compute for only one ring + # and then repeat + slope = (radius_bottom - radius_top) / height + ring_normals = np.empty(positions.shape[1:], dtype=np.float32) + ring_normals[..., :2] = ring_xy + ring_normals[..., 2] = slope + ring_normals /= np.linalg.norm(ring_normals, axis=-1)[:, None] + normals = np.empty_like(positions) + normals[:] = ring_normals[None, ...] + + # the TEXTURE COORDS + # u maps 0..1 to theta_start..theta_start+theta_length + # v maps 0..1 to -height/2..height/2 + ring_u = (theta - theta_start) / theta_length + ring_v = (heights / height) + 0.5 + texcoords = np.empty((n_rings, n_vertices, 2), dtype=np.float32) + texcoords[..., 0] = ring_u[None, :] + texcoords[..., 1] = ring_v[:, None] + + # the face INDEX + # the amount of vertices + indices = np.arange(n_rings * n_vertices, dtype=np.uint32).reshape( + (n_rings, n_vertices) + ) + # for every panel (height_segments, radial_segments) there is a quad (2, 3) + index = np.empty((height_segments, radial_segments, 2, 3), dtype=np.uint32) + # create a grid of initial indices for the panels + index[:, :, 0, 0] = indices[ + np.arange(height_segments)[:, None], np.arange(radial_segments)[None, :] + ] + # the remainder of the indices for every panel are relative + index[:, :, 0, 1] = index[:, :, 0, 0] + 1 + index[:, :, 0, 2] = index[:, :, 0, 0] + n_vertices + index[:, :, 1, 0] = index[:, :, 0, 0] + n_vertices + 1 + index[:, :, 1, 1] = index[:, :, 1, 0] - 1 + index[:, :, 1, 2] = index[:, :, 1, 0] - n_vertices + + return ( + positions.reshape((-1, 3)), + normals.reshape((-1, 3)), + texcoords.reshape((-1, 2)), + index.flatten(), + ) + + +def generate_cap(radius, height, radial_segments, theta_start, theta_length, up=True): + # compute POSITIONS assuming x-y horizontal plane and z up axis + + # to enable texture mapping to fully wrap around the cylinder, + # we can't close the geometry and need a degenerate vertex + n_vertices = radial_segments + 1 + + # xy coordinates on unit circle for vertex ring + theta = np.linspace( + theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32 + ) + ring_xy = np.column_stack([np.cos(theta), np.sin(theta)]) + + # put the vertices together, inserting a center vertex at the start + positions = np.empty((1 + n_vertices, 3), dtype=np.float32) + positions[0, :2] = [0.0, 0.0] + positions[1:, :2] = ring_xy * radius + positions[..., 2] = height + + # the NORMALS + normals = np.zeros_like(positions, dtype=np.float32) + sign = int(up) * 2.0 - 1.0 + normals[..., 2] = sign + + # the TEXTURE COORDS + # uv etches out a circle from the [0..1, 0..1] range + # direction is reversed for up=False + texcoords = np.empty((1 + n_vertices, 2), dtype=np.float32) + texcoords[0] = [0.5, 0.5] + texcoords[1:, 0] = ring_xy[:, 0] * 0.5 + 0.5 + texcoords[1:, 1] = ring_xy[:, 1] * 0.5 * sign + 0.5 + + # the face INDEX + indices = np.arange(n_vertices) + 1 + # for every radial segment there is a triangle (3) + index = np.empty((radial_segments, 3), dtype=np.uint32) + # create a grid of initial indices for the panels + index[:, 0] = indices[np.arange(radial_segments)] + # the remainder of the indices for every panel are relative + index[:, 1 + int(up)] = n_vertices + index[:, 2 - int(up)] = index[:, 0] + 1 + + return ( + positions, + normals, + texcoords, + index.flatten(), + ) + + +def create_vector_geometry( + spacing: float, + color: str | pygfx.Color | Sequence[float] | np.ndarray = "w", + cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None, + cone_radius_divisor: float = 10.0, + cone_height_divisor: float = 4.0, + stalk_radius_divisor: float = 30.0, + stalk_height_divisor: float = 4.0, + segments: int = 24, +): + cone_radius = spacing / cone_radius_divisor + stalk_radius = spacing / stalk_radius_divisor + radius_top = 0 + + cone_height = spacing / cone_height_divisor + stalk_height = spacing / stalk_height_divisor + radial_segments = segments + + height_segments = 1 + theta_start = 0.0 + theta_length = np.pi * 2 + + # create cone + cone = generate_torso( + cone_radius, + radius_top, + cone_height, + radial_segments, + height_segments, + theta_start, + theta_length, + ) + + groups = [cone] + + cone_cap_start_ix = len(cone[0]) + + # create bottom cap + cone_cap = generate_cap( + cone_radius, + -cone_height / 2, + radial_segments, + theta_start, + theta_length, + up=False, + ) + + cone_cap_stop_ix = cone_cap_start_ix + len(cone_cap[0]) + + groups.append(cone_cap) + + stalk = generate_torso( + stalk_radius, + stalk_radius, + stalk_height, + radial_segments, + height_segments, + theta_start, + theta_length, + z_offset=cone_height, + ) + + groups.append(stalk) + + stalk_cap = generate_cap( + stalk_radius, + -stalk_radius / 2, + radial_segments, + theta_start, + theta_length, + up=False, + ) + + groups.append(stalk_cap) + + merged = merge_geometries(groups) + + positions, normals, texcoords, indices = merged + + color = np.array(pygfx.Color(color).rgb, dtype=np.float32) + + # color the cone cap in a different color + if cone_cap_color is not None: + cone_cap_color = np.array(pygfx.Color(cone_cap_color).rgb, dtype=np.float32) + else: + # make the cone cap a slightly darker version of the cone color + cone_cap_color = (color - np.array([0.25, 0.25, 0.25], dtype=np.float32)).clip( + 0 + ) + + colors = np.repeat([color], repeats=len(positions), axis=0) + + colors[cone_cap_start_ix:cone_cap_stop_ix, :] = cone_cap_color + + return pygfx.Geometry( + indices=indices.reshape((-1, 3)), + positions=positions, + normals=normals, + texcoords=texcoords, + colors=colors, + ) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 96c76f9a8..ade82ed6a 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -103,6 +103,7 @@ def add_image_volume( ) -> ImageVolumeGraphic: """ + Create an ImageVolumeGraphic. Parameters ---------- @@ -110,7 +111,7 @@ def add_image_volume( array-like, usually numpy.ndarray, must support ``memoryview()``. Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) - mode: str, default "ray" + mode: str, default "mip" render mode, one of "mip", "minip", "iso" or "slice" vmin: float @@ -564,3 +565,59 @@ def add_text( anchor, **kwargs, ) + + def add_vector_field( + self, + positions: Union[numpy.ndarray, Sequence[float]], + directions: Union[numpy.ndarray, Sequence[float]], + spacing: float, + color: Union[str, Sequence[float], numpy.ndarray] = "w", + vector_shape_options: dict = None, + size_scaling_factor: float = 1.0, + **kwargs, + ) -> VectorField: + """ + + Create a Vector Field. Similar to matplotlib quiver. + + Parameters + ---------- + positions: np.ndarray | Sequence[float] + positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + directions: np.ndarray | Sequence[float] + directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + spacing: float + average distance between pairs of nearest-neighbor vectors, used for scaling + + color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" + color of the vectors + + vector_shape_options: dict + dict with the following fields that describe the shape of the vector arrows. + Larger values decrease the size of each component. + + * cone_radius_divisor, default 10.0 + * cone_height_divisor, default 4.0 + * stalk_radius_divisor, default 30.0 + * stalk_height_divisor, default 4.0 + + scaling_factor: float, default 1.0 + larger values will create larger vector arrows + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + VectorField, + positions, + directions, + spacing, + color, + vector_shape_options, + size_scaling_factor, + **kwargs, + ) From d10d08f369fdce57acc4d71f048e83c3e815c9d8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 Oct 2025 05:32:41 -0400 Subject: [PATCH 03/34] add vector field to api docs --- .../api/graphic_features/VectorDirections.rst | 35 +++++++++++++ .../api/graphic_features/VectorPositions.rst | 35 +++++++++++++ docs/source/api/graphic_features/index.rst | 2 + docs/source/api/graphics/VectorField.rst | 49 +++++++++++++++++++ docs/source/api/graphics/index.rst | 1 + docs/source/api/layouts/subplot.rst | 1 + fastplotlib/graphics/features/_vectors.py | 11 ++++- 7 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 docs/source/api/graphic_features/VectorDirections.rst create mode 100644 docs/source/api/graphic_features/VectorPositions.rst create mode 100644 docs/source/api/graphics/VectorField.rst diff --git a/docs/source/api/graphic_features/VectorDirections.rst b/docs/source/api/graphic_features/VectorDirections.rst new file mode 100644 index 000000000..99e47b4a1 --- /dev/null +++ b/docs/source/api/graphic_features/VectorDirections.rst @@ -0,0 +1,35 @@ +.. _api.VectorDirections: + +VectorDirections +**************** + +================ +VectorDirections +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections.add_event_handler + VectorDirections.block_events + VectorDirections.clear_event_handlers + VectorDirections.remove_event_handler + VectorDirections.set_value + diff --git a/docs/source/api/graphic_features/VectorPositions.rst b/docs/source/api/graphic_features/VectorPositions.rst new file mode 100644 index 000000000..939c00e00 --- /dev/null +++ b/docs/source/api/graphic_features/VectorPositions.rst @@ -0,0 +1,35 @@ +.. _api.VectorPositions: + +VectorPositions +*************** + +=============== +VectorPositions +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions.add_event_handler + VectorPositions.block_events + VectorPositions.clear_event_handlers + VectorPositions.remove_event_handler + VectorPositions.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index a2b4aec47..ca00a5d17 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -26,6 +26,8 @@ Graphic Features VolumeIsoEmissive VolumeIsoShininess VolumeSlicePlane + VectorPositions + VectorDirections TextData FontSize TextFaceColor diff --git a/docs/source/api/graphics/VectorField.rst b/docs/source/api/graphics/VectorField.rst new file mode 100644 index 000000000..551664699 --- /dev/null +++ b/docs/source/api/graphics/VectorField.rst @@ -0,0 +1,49 @@ +.. _api.VectorField: + +VectorField +*********** + +=========== +VectorField +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorField_api + + VectorField + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorField_api + + VectorField.alpha + VectorField.alpha_mode + VectorField.axes + VectorField.block_events + VectorField.deleted + VectorField.directions + VectorField.event_handlers + VectorField.name + VectorField.offset + VectorField.positions + VectorField.right_click_menu + VectorField.rotation + VectorField.supported_events + VectorField.visible + VectorField.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorField_api + + VectorField.add_axes + VectorField.add_event_handler + VectorField.clear_event_handlers + VectorField.remove_event_handler + VectorField.rotate + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index 640f76833..c340d9a47 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -9,6 +9,7 @@ Graphics ScatterGraphic ImageGraphic ImageVolumeGraphic + VectorField TextGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index bc2b3aa29..8ce8d939c 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -53,6 +53,7 @@ Methods Subplot.add_line_stack Subplot.add_scatter Subplot.add_text + Subplot.add_vector_field Subplot.auto_scale Subplot.center_graphic Subplot.center_scene diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 4aeef70eb..3f7ee6700 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -13,7 +13,9 @@ # it doesn't make sense to modify just a portion of a vector field, I can't think of a use case. # so we only allow setting the entire buffer, but allow getting portions of it class VectorPositions(GraphicFeature): - """Manages vector field positions by managing the mesh instance buffer""" + event_info_spec = [ + {"dict key": "value", "type": "np.ndarray", "description": "new vector positions"}, + ] def __init__( self, @@ -21,6 +23,8 @@ def __init__( isolated_buffer: bool = True, property_name: str = "positions", ): + """Manages vector field positions by managing the mesh instance buffer""" + positions = np.asarray(positions, dtype=np.float32) if positions.ndim != 2: raise ValueError( @@ -88,7 +92,9 @@ def set_value(self, graphic, value: np.ndarray): class VectorDirections(GraphicFeature): - """Manager vector field directions by interfacing with VectorBuffer manager""" + event_info_spec = [ + {"dict key": "value", "type": "np.ndarray", "description": "new vector directions"}, + ] def __init__( self, @@ -96,6 +102,7 @@ def __init__( isolated_buffer: bool = True, property_name: str = "directions", ): + """Manages vector field directions by interfacing with VectorBuffer manager""" directions = np.asarray(directions, dtype=np.float32) if directions.ndim != 2: raise ValueError( From 6858fd6b24cad27a5391d278070e6b6623fde3c9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 Oct 2025 05:32:57 -0400 Subject: [PATCH 04/34] update event tables --- docs/source/user_guide/event_tables.rst | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index d61bff2ee..4ff07fe51 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -697,6 +697,108 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +VectorField +----------- + +positions +^^^^^^^^^ + +**event info dict** + ++----------+------------+----------------------+ +| dict key | type | description | ++==========+============+======================+ +| value | np.ndarray | new vector positions | ++----------+------------+----------------------+ + +directions +^^^^^^^^^^ + +**event info dict** + ++----------+------------+-----------------------+ +| dict key | type | description | ++==========+============+=======================+ +| value | np.ndarray | new vector directions | ++----------+------------+-----------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + TextGraphic ----------- From 178ce12fb691956ab511c3e5dd6ea39ef13cc0ed Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 Oct 2025 05:53:46 -0400 Subject: [PATCH 05/34] add vector field to subsection_order --- docs/source/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 63ded9cca..9eef5e039 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -66,6 +66,7 @@ "../../examples/line", "../../examples/line_collection", "../../examples/scatter", + "../../examples/vector_field", "../../examples/text", "../../examples/events", "../../examples/selection_tools", From c336b16d38850b2d33a2d73166a82d1c10f2cda5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 28 Oct 2025 01:54:07 -0400 Subject: [PATCH 06/34] update vector field, almost there --- fastplotlib/graphics/features/_vectors.py | 9 +- fastplotlib/graphics/vector_field.py | 100 ++++++++++-------- fastplotlib/layouts/_graphic_methods_mixin.py | 56 +++++----- 3 files changed, 88 insertions(+), 77 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 3f7ee6700..62f349a5b 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -150,8 +150,6 @@ def set_value(self, graphic, value: np.ndarray): f"{value.shape[0]} != {self._directions.shape[0]}" ) - old_directions = self._directions.copy() - if value.shape[1] == 2: # assume 2d self._directions[:, :-1] = value @@ -159,16 +157,15 @@ def set_value(self, graphic, value: np.ndarray): else: self._directions[:] = value - # use the range of the 3D space to help set a scaling factor - range_3d = np.mean(np.ptp(graphic.positions[:], axis=0)) # vector determines the size of the vector - magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) / range_3d + magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) for i in range(self._directions.shape[0]): # get quaternion to rotate existing vector direction to new direction - rotation = la.quat_from_vecs(old_directions[i], self._directions[i]) + rotation = la.quat_from_vecs(np.array([0, 0, 1]), self._directions[i]) # get the new transform transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i]) + # set the buffer graphic.world_object.instance_buffer.data["matrix"][i] = transform.T diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index 24ffe1fd3..cb15b6158 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -22,10 +22,9 @@ def __init__( self, positions: np.ndarray | Sequence[float], directions: np.ndarray | Sequence[float], - spacing: float, color: str | Sequence[float] | np.ndarray = "w", + size: float = None, vector_shape_options: dict = None, - size_scaling_factor: float = 1.0, **kwargs, ): """ @@ -45,17 +44,19 @@ def __init__( color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" color of the vectors + size: float or None + Size of a vector of magnitude 1 in world space for display purpose. + Estimated from field density if not provided. + vector_shape_options: dict - dict with the following fields that describe the shape of the vector arrows. - Larger values decrease the size of each component. + dict with the following fields that directly describes the shape of the vector arrows. + Overrides ``size`` argument. - * cone_radius_divisor, default 10.0 - * cone_height_divisor, default 4.0 - * stalk_radius_divisor, default 30.0 - * stalk_height_divisor, default 4.0 - scaling_factor: float, default 1.0 - larger values will create larger vector arrows + * cone_radius + * cone_height + * stalk_radius + * stalk_height **kwargs passed to :class:`.Graphic` @@ -67,35 +68,53 @@ def __init__( self._positions = VectorPositions(positions) self._directions = VectorDirections(directions) - shape_options = dict( - cone_radius_divisor=10.0, - cone_height_divisor=4.0, - stalk_radius_divisor=30.0, - stalk_height_divisor=4.0, - ) - - if vector_shape_options is None: - vector_shape_options = {} - - for k in vector_shape_options: - if k not in shape_options: - raise KeyError( - f"valid dict fields for `vector_shape_options` are: {list(shape_options.keys())}. " - f"You passed the following dict: {vector_shape_options}" - ) - - shape_options = {**shape_options, **vector_shape_options} - - geometry = create_vector_geometry(spacing=spacing, color=color, **shape_options) + if size is None and vector_shape_options is None: + # guess from field density + + # sort xs and then take unique to get the density along x, same for y and z + x_density = np.diff(np.unique(np.sort(positions[:, 0]))).mean() + y_density = np.diff(np.unique(np.sort(positions[:, 1]))).mean() + densities = [x_density, y_density] + + if positions.shape[1] == 3: + z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() + densities.append(z_density) + print(densities) + mean_density = np.mean(densities) + + size = mean_density + + cone_height = size / 2 + stalk_height = size / 2 + + cone_radius = size / 10 + stalk_radius = cone_radius / 8 + + shape_options = { + "cone_radius": cone_radius, + "cone_height": cone_height, + "stalk_radius": stalk_radius, + "stalk_height": stalk_height, + } + + # if vector_shape_options is None: + # vector_shape_options = {} + # + # for k in vector_shape_options: + # if k not in shape_options: + # raise KeyError( + # f"valid dict fields for `vector_shape_options` are: {list(shape_options.keys())}. " + # f"You passed the following dict: {vector_shape_options}" + # ) + + geometry = create_vector_geometry(color=color, **shape_options) material = pygfx.MeshBasicMaterial() + n_vectors = self._positions.value.shape[0] world_object = pygfx.InstancedMesh(geometry, material, n_vectors) - range_3d = np.mean(np.ptp(self._positions[:], axis=0)) - magnitudes = ( - np.linalg.norm(self.directions[:], axis=1, ord=2) / range_3d - ) * size_scaling_factor + magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2) start_rot = np.array([0, 0, 1]) @@ -264,21 +283,16 @@ def generate_cap(radius, height, radial_segments, theta_start, theta_length, up= def create_vector_geometry( - spacing: float, color: str | pygfx.Color | Sequence[float] | np.ndarray = "w", cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None, - cone_radius_divisor: float = 10.0, - cone_height_divisor: float = 4.0, - stalk_radius_divisor: float = 30.0, - stalk_height_divisor: float = 4.0, + cone_radius: float = 10.0, + cone_height: float = 4.0, + stalk_radius: float = 30.0, + stalk_height: float = 4.0, segments: int = 24, ): - cone_radius = spacing / cone_radius_divisor - stalk_radius = spacing / stalk_radius_divisor radius_top = 0 - cone_height = spacing / cone_height_divisor - stalk_height = spacing / stalk_height_divisor radial_segments = segments height_segments = 1 diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index ade82ed6a..0e235ccb0 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -32,7 +32,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -80,7 +80,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -99,7 +99,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -182,7 +182,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -199,7 +199,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -268,7 +268,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -281,7 +281,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -332,7 +332,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -350,7 +350,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -427,7 +427,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -441,7 +441,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -499,7 +499,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_text( @@ -512,7 +512,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -563,18 +563,17 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vector_field( self, positions: Union[numpy.ndarray, Sequence[float]], directions: Union[numpy.ndarray, Sequence[float]], - spacing: float, color: Union[str, Sequence[float], numpy.ndarray] = "w", + size: float = None, vector_shape_options: dict = None, - size_scaling_factor: float = 1.0, - **kwargs, + **kwargs ) -> VectorField: """ @@ -594,17 +593,19 @@ def add_vector_field( color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" color of the vectors + size: float or None + Size of a vector of magnitude 1 in world space for display purpose. + Estimated from field density if not provided. + vector_shape_options: dict - dict with the following fields that describe the shape of the vector arrows. - Larger values decrease the size of each component. + dict with the following fields that directly describes the shape of the vector arrows. + Overrides ``size`` argument. - * cone_radius_divisor, default 10.0 - * cone_height_divisor, default 4.0 - * stalk_radius_divisor, default 30.0 - * stalk_height_divisor, default 4.0 - scaling_factor: float, default 1.0 - larger values will create larger vector arrows + * cone_radius + * cone_height + * stalk_radius + * stalk_height **kwargs passed to :class:`.Graphic` @@ -615,9 +616,8 @@ def add_vector_field( VectorField, positions, directions, - spacing, color, + size, vector_shape_options, - size_scaling_factor, - **kwargs, + **kwargs ) From f20547370fb58dbca67f581e2e73d98bb8aeaef1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 28 Oct 2025 02:12:49 -0400 Subject: [PATCH 07/34] vector field done and working --- fastplotlib/graphics/features/_vectors.py | 12 +++- fastplotlib/graphics/vector_field.py | 63 +++++++++---------- fastplotlib/layouts/_graphic_methods_mixin.py | 33 +++++----- 3 files changed, 55 insertions(+), 53 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 62f349a5b..235d20287 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -14,7 +14,11 @@ # so we only allow setting the entire buffer, but allow getting portions of it class VectorPositions(GraphicFeature): event_info_spec = [ - {"dict key": "value", "type": "np.ndarray", "description": "new vector positions"}, + { + "dict key": "value", + "type": "np.ndarray", + "description": "new vector positions", + }, ] def __init__( @@ -93,7 +97,11 @@ def set_value(self, graphic, value: np.ndarray): class VectorDirections(GraphicFeature): event_info_spec = [ - {"dict key": "value", "type": "np.ndarray", "description": "new vector directions"}, + { + "dict key": "value", + "type": "np.ndarray", + "description": "new vector directions", + }, ] def __init__( diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index cb15b6158..537c8619a 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -52,7 +52,6 @@ def __init__( dict with the following fields that directly describes the shape of the vector arrows. Overrides ``size`` argument. - * cone_radius * cone_height * stalk_radius @@ -68,21 +67,29 @@ def __init__( self._positions = VectorPositions(positions) self._directions = VectorDirections(directions) - if size is None and vector_shape_options is None: - # guess from field density - - # sort xs and then take unique to get the density along x, same for y and z - x_density = np.diff(np.unique(np.sort(positions[:, 0]))).mean() - y_density = np.diff(np.unique(np.sort(positions[:, 1]))).mean() - densities = [x_density, y_density] - - if positions.shape[1] == 3: - z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() - densities.append(z_density) - print(densities) - mean_density = np.mean(densities) - - size = mean_density + if vector_shape_options is not None: + required = {"cone_radius", "cone_height", "stalk_radius", "stalk_height"} + if set(vector_shape_options.keys()) != required: + raise KeyError( + f"`vector_shape_options` must be a dict with the following keys: {required}.\n" + f"You have passed: {vector_shape_options}" + ) + shape_options = vector_shape_options + else: + if size is None: + # guess from field density + # sort xs and then take unique to get the density along x, same for y and z + x_density = np.diff(np.unique(np.sort(positions[:, 0]))).mean() + y_density = np.diff(np.unique(np.sort(positions[:, 1]))).mean() + densities = [x_density, y_density] + + if positions.shape[1] == 3: + z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() + densities.append(z_density) + + mean_density = np.mean(densities) + + size = mean_density cone_height = size / 2 stalk_height = size / 2 @@ -97,16 +104,6 @@ def __init__( "stalk_height": stalk_height, } - # if vector_shape_options is None: - # vector_shape_options = {} - # - # for k in vector_shape_options: - # if k not in shape_options: - # raise KeyError( - # f"valid dict fields for `vector_shape_options` are: {list(shape_options.keys())}. " - # f"You passed the following dict: {vector_shape_options}" - # ) - geometry = create_vector_geometry(color=color, **shape_options) material = pygfx.MeshBasicMaterial() @@ -116,11 +113,9 @@ def __init__( magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2) - start_rot = np.array([0, 0, 1]) - for i in range(n_vectors): # get quaternion to rotate existing vector direction to new direction - rotation = la.quat_from_vecs(start_rot, self._directions[i]) + rotation = la.quat_from_vecs(np.array([0, 0, 1]), self._directions[i]) # get the new transform transform = la.mat_compose( self._positions.value[i], rotation, magnitudes[i] @@ -285,11 +280,11 @@ def generate_cap(radius, height, radial_segments, theta_start, theta_length, up= def create_vector_geometry( color: str | pygfx.Color | Sequence[float] | np.ndarray = "w", cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None, - cone_radius: float = 10.0, - cone_height: float = 4.0, - stalk_radius: float = 30.0, - stalk_height: float = 4.0, - segments: int = 24, + cone_radius: float = 1.0, + cone_height: float = 0.5, + stalk_radius: float = 0.3, + stalk_height: float = 0.5, + segments: int = 12, ): radius_top = 0 diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 0e235ccb0..fae7aa973 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -32,7 +32,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -80,7 +80,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_image_volume( @@ -99,7 +99,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -182,7 +182,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs + **kwargs, ) def add_line_collection( @@ -199,7 +199,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -268,7 +268,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -281,7 +281,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -332,7 +332,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -350,7 +350,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -427,7 +427,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_scatter( @@ -441,7 +441,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -499,7 +499,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs + **kwargs, ) def add_text( @@ -512,7 +512,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -563,7 +563,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vector_field( @@ -573,7 +573,7 @@ def add_vector_field( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorField: """ @@ -601,7 +601,6 @@ def add_vector_field( dict with the following fields that directly describes the shape of the vector arrows. Overrides ``size`` argument. - * cone_radius * cone_height * stalk_radius @@ -619,5 +618,5 @@ def add_vector_field( color, size, vector_shape_options, - **kwargs + **kwargs, ) From 0b9807577a308b04882848d55f73498a6711d1c0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 28 Oct 2025 02:24:05 -0400 Subject: [PATCH 08/34] cleanup --- fastplotlib/graphics/features/_vectors.py | 9 ++++++--- fastplotlib/graphics/vector_field.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 235d20287..f7398dfaf 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -104,6 +104,10 @@ class VectorDirections(GraphicFeature): }, ] + # vector is always pointing in [0, 0, 1] when mesh is initialized + init_direction = np.array([0, 0, 1]) + init_direction.flags.writeable = False + def __init__( self, directions: np.ndarray | Sequence[float], @@ -169,11 +173,10 @@ def set_value(self, graphic, value: np.ndarray): magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) for i in range(self._directions.shape[0]): - # get quaternion to rotate existing vector direction to new direction - rotation = la.quat_from_vecs(np.array([0, 0, 1]), self._directions[i]) + # get quaternion to rotate vector to new direction + rotation = la.quat_from_vecs(self.init_direction, self._directions[i]) # get the new transform transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i]) - # set the buffer graphic.world_object.instance_buffer.data["matrix"][i] = transform.T diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index 537c8619a..d76b92da5 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -114,8 +114,8 @@ def __init__( magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2) for i in range(n_vectors): - # get quaternion to rotate existing vector direction to new direction - rotation = la.quat_from_vecs(np.array([0, 0, 1]), self._directions[i]) + # get quaternion to rotate vector to new direction + rotation = la.quat_from_vecs(self._directions.init_direction, self._directions[i]) # get the new transform transform = la.mat_compose( self._positions.value[i], rotation, magnitudes[i] From f51a17ba592bbff529d7e08d08f20c8e849a7f76 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 28 Oct 2025 02:30:57 -0400 Subject: [PATCH 09/34] update docstring --- fastplotlib/graphics/features/_vectors.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index f7398dfaf..92a9f5a3a 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -27,7 +27,9 @@ def __init__( isolated_buffer: bool = True, property_name: str = "positions", ): - """Manages vector field positions by managing the mesh instance buffer""" + """ + Manages vector field positions by managing the translation elements of the mesh instance transform matrix buffer + """ positions = np.asarray(positions, dtype=np.float32) if positions.ndim != 2: @@ -114,7 +116,8 @@ def __init__( isolated_buffer: bool = True, property_name: str = "directions", ): - """Manages vector field directions by interfacing with VectorBuffer manager""" + """Manages vector field positions by managing the mesh instance buffer's full transform matrix""" + directions = np.asarray(directions, dtype=np.float32) if directions.ndim != 2: raise ValueError( From 7df8a9fe0553dd17dad828aff4f03ca16dd3d1cd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 03:17:18 -0400 Subject: [PATCH 10/34] update --- examples/vector_field/vector_field_simple.py | 2 -- examples/vector_field/vector_field_swirl.py | 2 -- fastplotlib/graphics/vector_field.py | 19 ++++++++++++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/examples/vector_field/vector_field_simple.py b/examples/vector_field/vector_field_simple.py index ba78c9395..f60a15cab 100644 --- a/examples/vector_field/vector_field_simple.py +++ b/examples/vector_field/vector_field_simple.py @@ -32,8 +32,6 @@ vector_field = figure[0, 0].add_vector_field( positions=positions, directions=directions, - spacing=step, - size_scaling_factor=5.0, ) figure.show() diff --git a/examples/vector_field/vector_field_swirl.py b/examples/vector_field/vector_field_swirl.py index 098e2fc20..a66adfa02 100644 --- a/examples/vector_field/vector_field_swirl.py +++ b/examples/vector_field/vector_field_swirl.py @@ -35,8 +35,6 @@ vector_field = figure[0, 0].add_vector_field( positions=positions, directions=directions, - spacing=step, - size_scaling_factor=2.0, ) figure.show() diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index d76b92da5..6c18ac14d 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -64,6 +64,18 @@ def __init__( super().__init__(**kwargs) + + # TODO: once it's possible to constructor instanced objects with a shared buffer I can do this + # if isinstance(positions, VectorPositions): + # self._positions = positions + # else: + # self._positions = VectorPositions(positions) + # + # if isinstance(directions, VectorDirections): + # self._directions = directions + # else: + # self._directions = VectorDirections(directions) + self._positions = VectorPositions(positions) self._directions = VectorDirections(directions) @@ -79,11 +91,12 @@ def __init__( if size is None: # guess from field density # sort xs and then take unique to get the density along x, same for y and z - x_density = np.diff(np.unique(np.sort(positions[:, 0]))).mean() - y_density = np.diff(np.unique(np.sort(positions[:, 1]))).mean() + x_density = np.diff(np.unique(np.sort(self._positions[:, 0]))).mean() + y_density = np.diff(np.unique(np.sort(self._positions[:, 1]))).mean() densities = [x_density, y_density] - if positions.shape[1] == 3: + # if z is not basically zero + if not np.allclose(np.diff(np.unique(np.sort(self._positions[:, 2]))), 0.0): z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() densities.append(z_density) From bec43aac42c9dff0a7122a4db78ad8b930ada753 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 15:20:57 -0400 Subject: [PATCH 11/34] black --- fastplotlib/layouts/_graphic_methods_mixin.py | 34 +++++++++---------- scripts/generate_add_graphic_methods.py | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 3a53377c7..41e12e1fd 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -34,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -82,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -101,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -184,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -201,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -270,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -283,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -334,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -352,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -429,7 +429,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -455,7 +455,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -497,7 +497,7 @@ def add_scatter( Supported values: * A string from pygfx.MarkerShape enum - * Matplotlib compatible characters: "osD+x^v<>". + * Matplotlib compatible characters: "osD+x^v<>*". * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, the WGSL @@ -584,7 +584,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs, + **kwargs ) def add_text( @@ -597,7 +597,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -648,7 +648,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vector_field( @@ -658,7 +658,7 @@ def add_vector_field( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorField: """ @@ -703,5 +703,5 @@ def add_vector_field( color, size, vector_shape_options, - **kwargs, + **kwargs ) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 46433180f..865eab27f 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -56,7 +56,7 @@ def generate_add_graphics_methods(): cls = m cls_name = cls.__name__.replace("Graphic", "") # from https://stackoverflow.com/a/1176023 - method_name = re.sub(r'(? Date: Thu, 30 Oct 2025 15:22:46 -0400 Subject: [PATCH 12/34] black --- fastplotlib/graphics/vector_field.py | 9 ++++-- fastplotlib/layouts/_graphic_methods_mixin.py | 32 +++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index 6c18ac14d..81abdbfe2 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -64,7 +64,6 @@ def __init__( super().__init__(**kwargs) - # TODO: once it's possible to constructor instanced objects with a shared buffer I can do this # if isinstance(positions, VectorPositions): # self._positions = positions @@ -96,7 +95,9 @@ def __init__( densities = [x_density, y_density] # if z is not basically zero - if not np.allclose(np.diff(np.unique(np.sort(self._positions[:, 2]))), 0.0): + if not np.allclose( + np.diff(np.unique(np.sort(self._positions[:, 2]))), 0.0 + ): z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() densities.append(z_density) @@ -128,7 +129,9 @@ def __init__( for i in range(n_vectors): # get quaternion to rotate vector to new direction - rotation = la.quat_from_vecs(self._directions.init_direction, self._directions[i]) + rotation = la.quat_from_vecs( + self._directions.init_direction, self._directions[i] + ) # get the new transform transform = la.mat_compose( self._positions.value[i], rotation, magnitudes[i] diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 41e12e1fd..20a8a97c8 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -34,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -82,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_image_volume( @@ -101,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -184,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs + **kwargs, ) def add_line_collection( @@ -201,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -270,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -283,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -334,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -352,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -429,7 +429,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_scatter( @@ -455,7 +455,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -584,7 +584,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs + **kwargs, ) def add_text( @@ -597,7 +597,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -648,7 +648,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vector_field( @@ -658,7 +658,7 @@ def add_vector_field( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorField: """ @@ -703,5 +703,5 @@ def add_vector_field( color, size, vector_shape_options, - **kwargs + **kwargs, ) From d5792bf8a15a21a76ccefe3dcebca116ff48a5b2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 15:23:09 -0400 Subject: [PATCH 13/34] update --- fastplotlib/graphics/features/_vectors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 92a9f5a3a..bf4a3971c 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -56,7 +56,7 @@ def __init__( self._positions = positions - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> np.ndarray: @@ -143,7 +143,7 @@ def __init__( self._directions = directions - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> np.ndarray: From 3820bfc409032355a2119075fc68883aa6ff1482 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 15:30:39 -0400 Subject: [PATCH 14/34] update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c599d5f8c..8735cd4e9 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/source/sg_execution_times.rst # PyBuilder target/ @@ -134,4 +135,6 @@ dmypy.json # vs code .vscode/ +# diffs from visual regression tests examples/desktop/diffs/*.png + From 7c56b8c8430e7e18012a82defa3639655d6e0a53 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 15:38:35 -0400 Subject: [PATCH 15/34] restore stuff in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fc922fad6..950f261c0 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ dmypy.json # diffs from visual regression tests examples/desktop/diffs/*.png +docs/source/_gallery/ From 049f3c55c8994d82e955c76303fb2c1a6816bd95 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 15:41:05 -0400 Subject: [PATCH 16/34] test examples --- examples/vector_field/vector_field_simple.py | 2 +- examples/vector_field/vector_field_swirl.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/vector_field/vector_field_simple.py b/examples/vector_field/vector_field_simple.py index f60a15cab..3bbd2739c 100644 --- a/examples/vector_field/vector_field_simple.py +++ b/examples/vector_field/vector_field_simple.py @@ -6,7 +6,7 @@ """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/vector_field/vector_field_swirl.py b/examples/vector_field/vector_field_swirl.py index a66adfa02..8e7a1bfea 100644 --- a/examples/vector_field/vector_field_swirl.py +++ b/examples/vector_field/vector_field_swirl.py @@ -6,7 +6,7 @@ """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np From 1f29bcaa9af3b197ad829f263fcf230ddf925980 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 17:15:35 -0400 Subject: [PATCH 17/34] update min pygfx version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 728a1cd1a..9ec1d6ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ keywords = [ requires-python = ">= 3.10" dependencies = [ "numpy>=1.23.0", - "pygfx==0.14", + "pygfx==0.15", "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", # (this comment keeps this list multiline in VSCode) From 1e8e64041317f57dc66e6d92a9279d6d9e39c67c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 17:30:10 -0400 Subject: [PATCH 18/34] ome zarr being annoying --- examples/image_volume/image_volume_multi_channel.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/image_volume/image_volume_multi_channel.py b/examples/image_volume/image_volume_multi_channel.py index 6d444e835..42bef658f 100644 --- a/examples/image_volume/image_volume_multi_channel.py +++ b/examples/image_volume/image_volume_multi_channel.py @@ -18,10 +18,8 @@ # read the image data reader = Reader(parse_url(url)) -# nodes may include images, labels etc -nodes = list(reader()) -# first node will be the image pixel data -image_node = nodes[0] +# first node is image data +image_node = next(reader()) dask_data = image_node.data From 82ee16a61d4333fc54c00396ff997924331c85a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 17:49:40 -0400 Subject: [PATCH 19/34] remove multi-channel from screenshot tests, only show code in nb --- examples/image_volume/image_volume_multi_channel.py | 4 ++-- examples/screenshots/image_volume_multi_channel.png | 3 --- examples/screenshots/no-imgui-image_volume_multi_channel.png | 3 --- 3 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 examples/screenshots/image_volume_multi_channel.png delete mode 100644 examples/screenshots/no-imgui-image_volume_multi_channel.png diff --git a/examples/image_volume/image_volume_multi_channel.py b/examples/image_volume/image_volume_multi_channel.py index 42bef658f..cf8902ce9 100644 --- a/examples/image_volume/image_volume_multi_channel.py +++ b/examples/image_volume/image_volume_multi_channel.py @@ -5,8 +5,8 @@ Example with multi-channel volume images. Use alpha_mode "add" for additive blending. """ -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' +# test_example = false +# sphinx_gallery_pygfx_docs = 'code' import fastplotlib as fpl from ome_zarr.io import parse_url diff --git a/examples/screenshots/image_volume_multi_channel.png b/examples/screenshots/image_volume_multi_channel.png deleted file mode 100644 index 18ae331de..000000000 --- a/examples/screenshots/image_volume_multi_channel.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66233e1fd5a227a674a730afb12dd1c445b57a5f3b953454715735ecd2f95db8 -size 194287 diff --git a/examples/screenshots/no-imgui-image_volume_multi_channel.png b/examples/screenshots/no-imgui-image_volume_multi_channel.png deleted file mode 100644 index b8e106ecb..000000000 --- a/examples/screenshots/no-imgui-image_volume_multi_channel.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:30b0f3b18dfeff123d5287012347fec09a9ab16277b5e6d2beb9039699da9954 -size 204901 From 8e269e060bcb05ba91b348739d71887520ed2902 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Oct 2025 18:05:07 -0400 Subject: [PATCH 20/34] don't run multi channel example --- examples/image_volume/image_volume_multi_channel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/image_volume/image_volume_multi_channel.py b/examples/image_volume/image_volume_multi_channel.py index cf8902ce9..01fc27ac6 100644 --- a/examples/image_volume/image_volume_multi_channel.py +++ b/examples/image_volume/image_volume_multi_channel.py @@ -6,6 +6,7 @@ """ # test_example = false +# run_example = false # sphinx_gallery_pygfx_docs = 'code' import fastplotlib as fpl From bf34866b2614857484a10a62a14410ce76d6f856 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 00:27:48 -0400 Subject: [PATCH 21/34] rename to VectorsGraphic since we will use field for something else --- docs/source/api/graphics/VectorsGraphic.rst | 49 +++++++++++++++++++ docs/source/api/graphics/index.rst | 2 +- docs/source/api/layouts/subplot.rst | 2 +- docs/source/conf.py | 2 +- examples/tests/testutils.py | 1 + examples/vector_field/README.rst | 2 - examples/vectors/README.rst | 2 + .../vector_field_simple.py | 8 +-- .../vector_field_swirl.py | 8 +-- fastplotlib/graphics/__init__.py | 4 +- fastplotlib/graphics/vector_field.py | 8 +-- fastplotlib/layouts/_graphic_methods_mixin.py | 42 ++++++++-------- 12 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 docs/source/api/graphics/VectorsGraphic.rst delete mode 100644 examples/vector_field/README.rst create mode 100644 examples/vectors/README.rst rename examples/{vector_field => vectors}/vector_field_simple.py (85%) rename examples/{vector_field => vectors}/vector_field_swirl.py (86%) diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst new file mode 100644 index 000000000..4a629f5db --- /dev/null +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -0,0 +1,49 @@ +.. _api.VectorsGraphic: + +VectorsGraphic +************** + +============== +VectorsGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic.alpha + VectorsGraphic.alpha_mode + VectorsGraphic.axes + VectorsGraphic.block_events + VectorsGraphic.deleted + VectorsGraphic.directions + VectorsGraphic.event_handlers + VectorsGraphic.name + VectorsGraphic.offset + VectorsGraphic.positions + VectorsGraphic.right_click_menu + VectorsGraphic.rotation + VectorsGraphic.supported_events + VectorsGraphic.visible + VectorsGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic.add_axes + VectorsGraphic.add_event_handler + VectorsGraphic.clear_event_handlers + VectorsGraphic.remove_event_handler + VectorsGraphic.rotate + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index c340d9a47..ac47a7dfd 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -9,7 +9,7 @@ Graphics ScatterGraphic ImageGraphic ImageVolumeGraphic - VectorField + VectorsGraphic TextGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 5cb7cdff5..4e40e8d08 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -54,7 +54,7 @@ Methods Subplot.add_line_stack Subplot.add_scatter Subplot.add_text - Subplot.add_vector_field + Subplot.add_vectors Subplot.auto_scale Subplot.center_graphic Subplot.center_scene diff --git a/docs/source/conf.py b/docs/source/conf.py index 9eef5e039..74a1fbaf9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -66,7 +66,7 @@ "../../examples/line", "../../examples/line_collection", "../../examples/scatter", - "../../examples/vector_field", + "../../examples/vectors", "../../examples/text", "../../examples/events", "../../examples/selection_tools", diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 546ff120e..7b70defdb 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -24,6 +24,7 @@ "scatter/*.py", "line/*.py", "line_collection/*.py", + "vectors/*.py" "gridplot/*.py", "window_layouts/*.py", "events/*.py", diff --git a/examples/vector_field/README.rst b/examples/vector_field/README.rst deleted file mode 100644 index 37f622377..000000000 --- a/examples/vector_field/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -Vector Field Examples -===================== diff --git a/examples/vectors/README.rst b/examples/vectors/README.rst new file mode 100644 index 000000000..a35457409 --- /dev/null +++ b/examples/vectors/README.rst @@ -0,0 +1,2 @@ +Vector Examples +=============== diff --git a/examples/vector_field/vector_field_simple.py b/examples/vectors/vector_field_simple.py similarity index 85% rename from examples/vector_field/vector_field_simple.py rename to examples/vectors/vector_field_simple.py index 3bbd2739c..f622e6001 100644 --- a/examples/vector_field/vector_field_simple.py +++ b/examples/vectors/vector_field_simple.py @@ -1,8 +1,8 @@ """ -Simple Vector Field -=================== +Simple Vectors +============== -Simple vector field example. Similar to matplotlib quiver. +Simple example with vectors. Similar to matplotlib quiver. """ @@ -29,7 +29,7 @@ directions = np.column_stack([u.ravel(), v.ravel()]) -vector_field = figure[0, 0].add_vector_field( +vectors = figure[0, 0].add_vectors( positions=positions, directions=directions, ) diff --git a/examples/vector_field/vector_field_swirl.py b/examples/vectors/vector_field_swirl.py similarity index 86% rename from examples/vector_field/vector_field_swirl.py rename to examples/vectors/vector_field_swirl.py index 8e7a1bfea..fcfcb86b0 100644 --- a/examples/vector_field/vector_field_swirl.py +++ b/examples/vectors/vector_field_swirl.py @@ -1,8 +1,8 @@ """ -Swirling vector field -===================== +Swirling vectors +================ -Example showing a swirling vector field. Similar to matplotlib quiver. +Example showing swirling vectors. Similar to matplotlib quiver. """ @@ -32,7 +32,7 @@ directions = np.column_stack([u.ravel(), v.ravel(), w.ravel()]) -vector_field = figure[0, 0].add_vector_field( +vectors = figure[0, 0].add_vectors( positions=positions, directions=directions, ) diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 4576a3b9b..a225de5ba 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -3,7 +3,7 @@ from .scatter import ScatterGraphic from .image import ImageGraphic from .image_volume import ImageVolumeGraphic -from .vector_field import VectorField +from .vector_field import VectorsGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -14,7 +14,7 @@ "ScatterGraphic", "ImageGraphic", "ImageVolumeGraphic", - "VectorField", + "VectorsGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/vector_field.py index 81abdbfe2..689fce863 100644 --- a/fastplotlib/graphics/vector_field.py +++ b/fastplotlib/graphics/vector_field.py @@ -12,7 +12,7 @@ ) -class VectorField(Graphic): +class VectorsGraphic(Graphic): _features = { "positions": VectorPositions, "directions": VectorDirections, @@ -28,7 +28,7 @@ def __init__( **kwargs, ): """ - Create a Vector Field. Similar to matplotlib quiver. + Create graphic that draw vectors. Similar to matplotlib quiver. Parameters ---------- @@ -46,7 +46,7 @@ def __init__( size: float or None Size of a vector of magnitude 1 in world space for display purpose. - Estimated from field density if not provided. + Estimated from density if not provided. vector_shape_options: dict dict with the following fields that directly describes the shape of the vector arrows. @@ -88,7 +88,7 @@ def __init__( shape_options = vector_shape_options else: if size is None: - # guess from field density + # guess from density # sort xs and then take unique to get the density along x, same for y and z x_density = np.diff(np.unique(np.sort(self._positions[:, 0]))).mean() y_density = np.diff(np.unique(np.sort(self._positions[:, 1]))).mean() diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 7b32e7e0a..a708f10d5 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -34,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -82,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -101,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -184,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -201,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -270,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -283,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -334,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -352,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -429,7 +429,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -455,7 +455,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -583,7 +583,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs, + **kwargs ) def add_text( @@ -596,7 +596,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -647,21 +647,21 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) - def add_vector_field( + def add_vectors( self, positions: Union[numpy.ndarray, Sequence[float]], directions: Union[numpy.ndarray, Sequence[float]], color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, - ) -> VectorField: + **kwargs + ) -> VectorsGraphic: """ - Create a Vector Field. Similar to matplotlib quiver. + Create graphic that draw vectors. Similar to matplotlib quiver. Parameters ---------- @@ -679,7 +679,7 @@ def add_vector_field( size: float or None Size of a vector of magnitude 1 in world space for display purpose. - Estimated from field density if not provided. + Estimated from density if not provided. vector_shape_options: dict dict with the following fields that directly describes the shape of the vector arrows. @@ -696,11 +696,11 @@ def add_vector_field( """ return self._create_graphic( - VectorField, + VectorsGraphic, positions, directions, color, size, vector_shape_options, - **kwargs, + **kwargs ) From b1f23bcf81b7e5d3b3ec2e79d2e7535bc8814bd0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:25:28 -0500 Subject: [PATCH 22/34] interactive electric field example --- .../vectors/vector_field_interact_charges.py | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 examples/vectors/vector_field_interact_charges.py diff --git a/examples/vectors/vector_field_interact_charges.py b/examples/vectors/vector_field_interact_charges.py new file mode 100644 index 000000000..4ea3cbc8c --- /dev/null +++ b/examples/vectors/vector_field_interact_charges.py @@ -0,0 +1,181 @@ +""" +View Electric Field +=================== + +Interactively move the charges around by clicking and dragging the mouse to see +the static field with the charges at their new positions. This is just computing +static fields, not electrodynamics or magnetic field effects are taken into account. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import pygfx + + +# based on vacuum permittivity, 1/4πε from wikipedia: https://en.wikipedia.org/wiki/Coulomb%27s_law#Coulomb_constant +k_e = 8.98755 * 10**9 + + +def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: + """ + Compute force on a unit charge at a distance ``r`` from a particle of charge ``q``. + Broadcasts over ``r`` array. + + q: charge in coulombs + r: 2D array of distance vectors, shape [n, 2] + + Returns force vector at each distance ``r`` provided, shape [n, 2] + """ + r_cap = r / np.linalg.norm(r, ord=2, axis=1)[:, None] + F = k_e * ((q * r_cap) / ((np.linalg.norm(r, ord=2, axis=1))**2)[:, None]) + + return F + + +figure = fpl.Figure(size=(700, 750)) + +# positions of 3 particles, ignore z +positions = np.array([ + [3, 3, 0], + [7, 7, 0], + [4, 8, 0], +]) + +# charges of the 3 particles +charges = np.array([ + 3.5 * 10**-10, + 1 * 10**-10, + -3.5 * 10**-10, +]) + +# red to indicate positive charge, blue to indicate negative charge +colors = ["r", "r", "b"] + +# scatter point to indicate particle positions +particles = figure[0, 0].add_scatter( + data=positions, + colors=colors, + sizes=1, + edge_width=0.1, + uniform_edge_color=False, + alpha=0.7, + size_space="model", + metadata={"charges": charges}, # you can store anything as arbitrary metadata + alpha_mode="blend", +) +particles.world_object.render_order + + +xs = np.linspace(0, 10, num=20) +ys = np.linspace(0, 10, num=20) + +x, y = np.meshgrid(xs, ys) + +# display vectors at these positions in the field +field_positions = np.column_stack([x.ravel(), y.ravel()]) + +# direction of the field at every position due to the particle's charge +# i.e., the force felt by a unit charge at a given position in the field +field_directions = np.ones(field_positions.shape, dtype=np.float32) + +vectors = figure[0, 0].add_vectors( + positions=field_positions, + directions=field_directions, +) + + +def update_field(): + """update the static field w.r.t. the new positions of the particles""" + + # get force vectors due to each charge and add them up + force_vectors_total = np.zeros(field_positions.shape) + + for i in range(particles.data.value.shape[0]): + force_vectors = coulombs_law( + q=particles.metadata["charges"][i], # force due to one of the charges + r=particles.data[:, :-1][i] - field_positions + ) + + force_vectors_total = force_vectors_total + force_vectors + + # zero out when the force is too large to display + # large vectors will otherwise take up the entire plot area + force_vectors_total[np.linalg.norm(force_vectors_total, axis=1, ord=2) > 3.5] = 0 + + # update the graphic + vectors.directions = force_vectors_total + + +update_field() + +# render particles on top of field +particles.world_object.material.render_queue = fpl.utils.RenderQueue.selector + +# interactivity code, very similar to the "Drag points" example +is_moving = False +particle_index = None +# interact with particles by moving them with mouse +@particles.add_event_handler("pointer_down") +def start_drag(ev: pygfx.PointerEvent): + global is_moving + global particle_index + + if ev.button != 1: + return + + is_moving = True + particle_index = ev.pick_info["vertex_index"] + # set edge color to indicate this particle has been selected + particles.edge_colors[particle_index] = "y" + + +@figure.renderer.add_event_handler("pointer_move") +def move_point(ev): + global is_moving + global particle_index + + # if not moving, return + if not is_moving: + return + + # pause controller so mouse events move the scatter and not the camera + with figure[0, 0].controller.pause(): + # map x, y from screen space to world space + pos = figure[0, 0].map_screen_to_world(ev) + + if pos is None: + # end movement + is_moving = False + particle_index = None + return + + # change scatter data + particles.data[particle_index, :-1] = pos[:-1] + # update field + update_field() + + +@figure.renderer.add_event_handler("pointer_up") +def end_drag(ev: pygfx.PointerEvent): + global is_moving + global particle_index + + # end movement + if is_moving: + # reset color + particles.edge_colors[particle_index] = "k" + + is_moving = False + particle_index = None + + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() From 3f4378617fcb5f3faf77cbe30dae0b3b10050b05 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:26:20 -0500 Subject: [PATCH 23/34] update name --- fastplotlib/graphics/__init__.py | 2 +- fastplotlib/graphics/{vector_field.py => _vectors.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename fastplotlib/graphics/{vector_field.py => _vectors.py} (100%) diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index a225de5ba..46051479d 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -3,7 +3,7 @@ from .scatter import ScatterGraphic from .image import ImageGraphic from .image_volume import ImageVolumeGraphic -from .vector_field import VectorsGraphic +from ._vectors import VectorsGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack diff --git a/fastplotlib/graphics/vector_field.py b/fastplotlib/graphics/_vectors.py similarity index 100% rename from fastplotlib/graphics/vector_field.py rename to fastplotlib/graphics/_vectors.py From 1d6ea48c93aa560ebee032500d6c8d3bea96130b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:27:40 -0500 Subject: [PATCH 24/34] black --- fastplotlib/layouts/_graphic_methods_mixin.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index a708f10d5..e7ff99a1d 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -34,7 +34,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -82,7 +82,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_image_volume( @@ -101,7 +101,7 @@ def add_image_volume( emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -184,7 +184,7 @@ def add_image_volume( emissive, shininess, isolated_buffer, - **kwargs + **kwargs, ) def add_line_collection( @@ -201,7 +201,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -270,7 +270,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -283,7 +283,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -334,7 +334,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -352,7 +352,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -429,7 +429,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_scatter( @@ -455,7 +455,7 @@ def add_scatter( uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -583,7 +583,7 @@ def add_scatter( uniform_size, size_space, isolated_buffer, - **kwargs + **kwargs, ) def add_text( @@ -596,7 +596,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -647,7 +647,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vectors( @@ -657,7 +657,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -702,5 +702,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs + **kwargs, ) From 8769dce9e69e3045c492b62cae73f147af1c79c2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:28:00 -0500 Subject: [PATCH 25/34] update name --- docs/source/user_guide/event_tables.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index b8696721b..8e942830e 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -795,8 +795,8 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -VectorField ------------ +VectorsGraphic +-------------- positions ^^^^^^^^^ From 33e8fae7f58bcb6179de0bb257e4a35b55e42051 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:36:38 -0500 Subject: [PATCH 26/34] wrong sign, now correct --- examples/vectors/vector_field_interact_charges.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/vectors/vector_field_interact_charges.py b/examples/vectors/vector_field_interact_charges.py index 4ea3cbc8c..5f1f0ce56 100644 --- a/examples/vectors/vector_field_interact_charges.py +++ b/examples/vectors/vector_field_interact_charges.py @@ -40,7 +40,7 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: # positions of 3 particles, ignore z positions = np.array([ [3, 3, 0], - [7, 7, 0], + [8, 5, 0], [4, 8, 0], ]) @@ -96,7 +96,7 @@ def update_field(): for i in range(particles.data.value.shape[0]): force_vectors = coulombs_law( q=particles.metadata["charges"][i], # force due to one of the charges - r=particles.data[:, :-1][i] - field_positions + r=field_positions - particles.data[:, :-1][i] ) force_vectors_total = force_vectors_total + force_vectors From b801e7fe51dd801479d79218ef012ead82c1d2d4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Nov 2025 01:38:22 -0500 Subject: [PATCH 27/34] update api docs --- docs/source/api/graphics/VectorField.rst | 49 ------------------------ 1 file changed, 49 deletions(-) delete mode 100644 docs/source/api/graphics/VectorField.rst diff --git a/docs/source/api/graphics/VectorField.rst b/docs/source/api/graphics/VectorField.rst deleted file mode 100644 index 551664699..000000000 --- a/docs/source/api/graphics/VectorField.rst +++ /dev/null @@ -1,49 +0,0 @@ -.. _api.VectorField: - -VectorField -*********** - -=========== -VectorField -=========== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: VectorField_api - - VectorField - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: VectorField_api - - VectorField.alpha - VectorField.alpha_mode - VectorField.axes - VectorField.block_events - VectorField.deleted - VectorField.directions - VectorField.event_handlers - VectorField.name - VectorField.offset - VectorField.positions - VectorField.right_click_menu - VectorField.rotation - VectorField.supported_events - VectorField.visible - VectorField.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: VectorField_api - - VectorField.add_axes - VectorField.add_event_handler - VectorField.clear_event_handlers - VectorField.remove_event_handler - VectorField.rotate - From da752af35b836d415eac2f60eec1b0ae3b8e5f85 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Sun, 2 Nov 2025 19:23:42 -0500 Subject: [PATCH 28/34] Apply suggestion from @clewis7 Co-authored-by: Caitlin Lewis --- examples/vectors/vector_field_interact_charges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/vectors/vector_field_interact_charges.py b/examples/vectors/vector_field_interact_charges.py index 5f1f0ce56..f25e7c8c7 100644 --- a/examples/vectors/vector_field_interact_charges.py +++ b/examples/vectors/vector_field_interact_charges.py @@ -4,7 +4,7 @@ Interactively move the charges around by clicking and dragging the mouse to see the static field with the charges at their new positions. This is just computing -static fields, not electrodynamics or magnetic field effects are taken into account. +static fields, no electrodynamics or magnetic field effects are taken into account. """ # test_example = false From cfecfa3d8ac5c061561cf3086e2a740124237d6d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:19:53 -0500 Subject: [PATCH 29/34] fix render order and blending, add comment --- examples/vectors/vector_field_interact_charges.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/vectors/vector_field_interact_charges.py b/examples/vectors/vector_field_interact_charges.py index f25e7c8c7..1e8f567d3 100644 --- a/examples/vectors/vector_field_interact_charges.py +++ b/examples/vectors/vector_field_interact_charges.py @@ -5,6 +5,7 @@ Interactively move the charges around by clicking and dragging the mouse to see the static field with the charges at their new positions. This is just computing static fields, no electrodynamics or magnetic field effects are taken into account. + """ # test_example = false @@ -59,15 +60,13 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: data=positions, colors=colors, sizes=1, - edge_width=0.1, + edge_width=0.05, uniform_edge_color=False, alpha=0.7, size_space="model", metadata={"charges": charges}, # you can store anything as arbitrary metadata alpha_mode="blend", ) -particles.world_object.render_order - xs = np.linspace(0, 10, num=20) ys = np.linspace(0, 10, num=20) @@ -84,6 +83,8 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: vectors = figure[0, 0].add_vectors( positions=field_positions, directions=field_directions, + alpha=0.7, + alpha_mode="blend", ) @@ -112,7 +113,7 @@ def update_field(): update_field() # render particles on top of field -particles.world_object.material.render_queue = fpl.utils.RenderQueue.selector +particles.world_object.material.render_queue = vectors.world_object.material.render_queue + 1 # interactivity code, very similar to the "Drag points" example is_moving = False @@ -123,7 +124,7 @@ def start_drag(ev: pygfx.PointerEvent): global is_moving global particle_index - if ev.button != 1: + if ev.button != 1: # check for left mouse button return is_moving = True From 675ec0b7449d06ba4902aa1952b80b50f0d53aa1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:22:46 -0500 Subject: [PATCH 30/34] rename --- ...teract_charges.py => vectors_interact_electric_charges.py} | 4 ++-- .../vectors/{vector_field_simple.py => vectors_simple.py} | 2 +- examples/vectors/{vector_field_swirl.py => vectors_swirl.py} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename examples/vectors/{vector_field_interact_charges.py => vectors_interact_electric_charges.py} (99%) rename examples/vectors/{vector_field_simple.py => vectors_simple.py} (93%) rename examples/vectors/{vector_field_swirl.py => vectors_swirl.py} (100%) diff --git a/examples/vectors/vector_field_interact_charges.py b/examples/vectors/vectors_interact_electric_charges.py similarity index 99% rename from examples/vectors/vector_field_interact_charges.py rename to examples/vectors/vectors_interact_electric_charges.py index 1e8f567d3..5d43930bb 100644 --- a/examples/vectors/vector_field_interact_charges.py +++ b/examples/vectors/vectors_interact_electric_charges.py @@ -1,6 +1,6 @@ """ -View Electric Field -=================== +Static Electric Field +===================== Interactively move the charges around by clicking and dragging the mouse to see the static field with the charges at their new positions. This is just computing diff --git a/examples/vectors/vector_field_simple.py b/examples/vectors/vectors_simple.py similarity index 93% rename from examples/vectors/vector_field_simple.py rename to examples/vectors/vectors_simple.py index f622e6001..e26d6da71 100644 --- a/examples/vectors/vector_field_simple.py +++ b/examples/vectors/vectors_simple.py @@ -19,7 +19,7 @@ # get uniform x, y positions x, y = np.meshgrid(np.arange(start, stop, step), np.arange(start, stop, step)) -# vectors, u and v are x and y components indication directions +# vectors, u and v are x and y components indicating directions u = np.cos(x) v = np.sin(y) diff --git a/examples/vectors/vector_field_swirl.py b/examples/vectors/vectors_swirl.py similarity index 100% rename from examples/vectors/vector_field_swirl.py rename to examples/vectors/vectors_swirl.py From 7b82f976bdd6002f027bc1eae0f59c1349416c33 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:26:10 -0500 Subject: [PATCH 31/34] docstrings, comments --- fastplotlib/graphics/_vectors.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index 689fce863..93ca2fb90 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -173,6 +173,7 @@ def generate_torso( theta_length, z_offset=0.0, ): + """copied from pygfx, generates the mesh for a cylinder with the given parameters""" # compute POSITIONS assuming x-y horizontal plane and z up axis # radius for each vertex ring from bottom to top @@ -244,6 +245,7 @@ def generate_torso( def generate_cap(radius, height, radial_segments, theta_start, theta_length, up=True): + """copied from pygfx, generates the mesh for a circular cap with the given parameters""" # compute POSITIONS assuming x-y horizontal plane and z up axis # to enable texture mapping to fully wrap around the cylinder, @@ -302,7 +304,35 @@ def create_vector_geometry( stalk_height: float = 0.5, segments: int = 12, ): - radius_top = 0 + """ + Generate the mesh for a vector pointing in the direction [0, 0, 1], a unit vector in the +z direction. + + Parameters + ---------- + color: + color of the vector + + cone_cap_color: + color of the cone cap, by default it will use a darker version of the provided vector color from above. + + cone_radius: + radius of the bottom of the cone segment of the vector + + cone_height: + height of the cone segment of the vector + + stalk_radius: + radius of the vector's stalk + + stalk_height: + height of the vector's stalk + + segments: + number of mesh segments, more looks nicers but is also more expennsive to render, 12 looks good enough. + + """ + + radius_top = 0 # radius top = 0 means the cylinder becomes a cone radial_segments = segments From 6ea00e64cde56b09032bb5e38cb97f5bc326d166 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:31:52 -0500 Subject: [PATCH 32/34] checks --- fastplotlib/graphics/_vectors.py | 9 +++++++++ fastplotlib/graphics/features/_vectors.py | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index 93ca2fb90..6f761bd49 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -75,6 +75,15 @@ def __init__( # else: # self._directions = VectorDirections(directions) + positions = np.asarray(positions) + directions = np.asarray(directions) + + if positions.shape != directions.shape: + raise ValueError( + f"positions.shape != directions.shape: {positions.shape} != {directions.shape}\n" + f"They must be of the same shape" + ) + self._positions = VectorPositions(positions) self._directions = VectorDirections(directions) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index bf4a3971c..86f999561 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -23,7 +23,7 @@ class VectorPositions(GraphicFeature): def __init__( self, - positions: np.ndarray | Sequence[float], + positions: np.ndarray, isolated_buffer: bool = True, property_name: str = "positions", ): @@ -112,7 +112,7 @@ class VectorDirections(GraphicFeature): def __init__( self, - directions: np.ndarray | Sequence[float], + directions: np.ndarray, isolated_buffer: bool = True, property_name: str = "directions", ): From e33f8f7e1034c21ac0f1af3a649a4093a170ab7e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:55:44 -0500 Subject: [PATCH 33/34] clarify example --- .../vectors/vectors_interact_electric_charges.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/vectors/vectors_interact_electric_charges.py b/examples/vectors/vectors_interact_electric_charges.py index 5d43930bb..4aa1d41b6 100644 --- a/examples/vectors/vectors_interact_electric_charges.py +++ b/examples/vectors/vectors_interact_electric_charges.py @@ -38,11 +38,11 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: figure = fpl.Figure(size=(700, 750)) -# positions of 3 particles, ignore z +# positions of 3 particles in a 2d plane positions = np.array([ - [3, 3, 0], - [8, 5, 0], - [4, 8, 0], + [3, 3], + [8, 5], + [4, 8], ]) # charges of the 3 particles @@ -76,9 +76,9 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: # display vectors at these positions in the field field_positions = np.column_stack([x.ravel(), y.ravel()]) -# direction of the field at every position due to the particle's charge +# allocate array to store direction of the field at every position due to the charge of the 3 particles # i.e., the force felt by a unit charge at a given position in the field -field_directions = np.ones(field_positions.shape, dtype=np.float32) +field_directions = np.zeros(field_positions.shape, dtype=np.float32) vectors = figure[0, 0].add_vectors( positions=field_positions, From e5d0488234bc4a76cecd1472bd8461a06055b3bc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Nov 2025 00:58:08 -0500 Subject: [PATCH 34/34] cleanup --- fastplotlib/graphics/features/_vectors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 86f999561..9c86d25fc 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -1,5 +1,3 @@ -from typing import Sequence - import numpy as np import pylinalg as la