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,
)
[docs]
class VectorsGraphic(Graphic):
_features = {
"positions": VectorPositions,
"directions": VectorDirections,
}
def __init__(
self,
positions: np.ndarray | Sequence[float],
directions: np.ndarray | Sequence[float],
color: str | Sequence[float] | np.ndarray = "w",
size: float = None,
vector_shape_options: dict = None,
**kwargs,
):
"""
Create graphic that draw vectors. 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
size: float or None
Size of a vector of magnitude 1 in world space for display purpose.
Estimated from density if not provided.
vector_shape_options: dict
dict with the following fields that directly describes the shape of the vector arrows.
Overrides ``size`` argument.
* cone_radius
* cone_height
* stalk_radius
* stalk_height
**kwargs
passed to :class:`.Graphic`
"""
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)
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)
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 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()
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
):
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
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,
}
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)
magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2)
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]
)
# 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,
):
"""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
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):
"""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,
# 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(
color: str | pygfx.Color | Sequence[float] | np.ndarray = "w",
cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None,
cone_radius: float = 1.0,
cone_height: float = 0.5,
stalk_radius: float = 0.3,
stalk_height: float = 0.5,
segments: int = 12,
):
"""
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
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,
)