import numpy as np
import pylinalg as la
from ._base import (
GraphicFeature,
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
[docs]
class VectorPositions(GraphicFeature):
event_info_spec = [
{
"dict key": "value",
"type": "np.ndarray",
"description": "new vector positions",
},
]
def __init__(
self,
positions: np.ndarray,
property_name: str = "positions",
):
"""
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:
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
super().__init__(property_name=property_name)
@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: "
f"{value.shape[0]} != {self._positions.shape[0]}"
)
if value.shape[1] == 2:
# assume 2d
self._positions[:, :-1] = value
else:
self._positions[:] = value
# Only need to update the translation vector
graphic.world_object.instance_buffer.data["matrix"][:, 3, 0:3] = (
self._positions[:]
)
graphic.world_object.instance_buffer.update_full()
event = GraphicFeatureEvent(type="positions", info={"value": value})
self._call_event_handlers(event)
[docs]
class VectorDirections(GraphicFeature):
event_info_spec = [
{
"dict key": "value",
"type": "np.ndarray",
"description": "new vector directions",
},
]
# 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,
property_name: str = "directions",
):
"""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(
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_name=property_name)
@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]}"
)
if value.shape[1] == 2:
# assume 2d
self._directions[:, :-1] = value
else:
self._directions[:] = value
# vector determines the size of the vector
magnitudes = np.linalg.norm(self._directions, axis=1, ord=2)
rotation = quat_from_vecs(self.init_direction, self._directions[:])
# get the new transform
transform = mat_compose(graphic.positions[:], rotation, magnitudes[:])
# set the buffer
graphic.world_object.instance_buffer.data["matrix"][:] = transform.transpose(
0, 2, 1
)
graphic.world_object.instance_buffer.update_full()
event = GraphicFeatureEvent(type="directions", info={"value": value})
self._call_event_handlers(event)
def quat_from_vecs(source, target, out=None, dtype=None) -> np.ndarray:
source = np.asarray(source, dtype=float)
if source.ndim == 1:
source = source[None, :]
target = np.asarray(target, dtype=float)
if target.ndim == 1:
target = target[None, :]
num_vecs = target.shape[0]
result_shape = (num_vecs, 4)
if out is None:
out = np.empty(result_shape, dtype=dtype)
axis = np.cross(source, target) # (num_pts, 3)
axis_norm = np.linalg.norm(axis, axis=-1) # (num_pts,)
angle = np.arctan2(axis_norm, (target @ source.T).squeeze(1)) # (num_pts,)
# Handle degenerate case: source and target are parallel (axis is zero vector).
# Pick any axis orthogonal to source as a replacement.
use_fallback = axis_norm == 0
if np.any(use_fallback):
t = np.broadcast_to(source, (num_vecs, 3))[use_fallback]
# Better case split:
y_zero = t[:, 1] == 0
z_zero = t[:, 2] == 0
neither_zero = ~y_zero & ~z_zero
fb = np.empty((y_zero.shape[0], 3), dtype=float)
fb[y_zero] = (0.0, 1.0, 0.0)
fb[~y_zero & z_zero] = (0.0, 0.0, 1.0)
fb[neither_zero, 0] = 0.0
fb[neither_zero, 1] = -t[neither_zero, 2]
fb[neither_zero, 2] = t[neither_zero, 1]
axis[use_fallback] = fb
return quat_from_axis_angle(axis, angle, out=out)
def quat_from_axis_angle(axis, angle, out=None, dtype=None) -> np.ndarray:
"""Quaternion from axis-angle pair.
Create a quaternion representing the rotation of an given angle
about a given unit vector
Parameters
----------
axis : ndarray, [num_vectors, 3] or [3]
Unit vector
angle : float | np.ndarray
The angle (in radians) to rotate about axis, a scalar or an array of shape [num_pts,]
out : ndarray, optional
A location into which the result is stored. If provided, it
must have a shape that the inputs broadcast to. If not provided or
None, a freshly-allocated array is returned. A tuple must have
length equal to the number of outputs.
dtype : data-type, optional
Overrides the data type of the result.
Returns
-------
ndarray, [num_pts, 4] or [4]
Quaternion.
"""
axis = np.asarray(axis, dtype=float)
angle = np.asarray(angle, dtype=float)
if out is None:
out_shape = np.broadcast_shapes(axis.shape[:-1], angle.shape)
out = np.empty((*out_shape, 4), dtype=dtype)
# result should be independent of the length of the given axis
lengths_shape = (*axis.shape[:-1], 1)
axis = axis / np.linalg.norm(axis, axis=-1).reshape(lengths_shape)
out[..., :3] = axis * np.sin(angle / 2).reshape(lengths_shape)
out[..., 3] = np.cos(angle / 2)
return out.squeeze(0) if out.shape[0] == 1 else out
def mat_compose(
translation, rotation, scaling, /, *, out=None, dtype=None
) -> np.ndarray:
"""
Compose transformation matrices given translation vectors, quaternions,
and scaling vectors.
Parameters
----------
translation : ndarray, [3] or [num_vectors, 3]
rotation : ndarray, [4] or [num_vectors, 4]
scaling : ndarray, [3] or [num_vectors, 3]
Returns
-------
np.ndarray
[num_vectors, 4, 4] or [4, 4]
"""
rotation = np.asarray(rotation, dtype=float)
translation = np.asarray(translation, dtype=float)
scaling = np.asarray(scaling, dtype=float)
if rotation.ndim == 1:
rotation = rotation[None, :]
if translation.ndim == 1:
translation = translation[None, :]
if scaling.ndim == 0:
scaling = np.full((1, 3), scaling)
elif scaling.ndim == 1 and scaling.shape[0] == 3:
scaling = scaling[None, :]
elif scaling.ndim == 1:
scaling = scaling[:, None] * np.ones(3)
num_vectors = max(rotation.shape[0], translation.shape[0], scaling.shape[0])
if out is None:
out = np.zeros((num_vectors, 4, 4), dtype=dtype)
else:
out[..., :, :] = 0
x, y, z, w = rotation[:, 0], rotation[:, 1], rotation[:, 2], rotation[:, 3]
x2, y2, z2 = x + x, y + y, z + z
xx, xy, xz = x * x2, x * y2, x * z2
yy, yz, zz = y * y2, y * z2, z * z2
wx, wy, wz = w * x2, w * y2, w * z2
sx, sy, sz = scaling[:, 0], scaling[:, 1], scaling[:, 2]
out[:, 0, 0] = (1 - (yy + zz)) * sx
out[:, 1, 0] = (xy + wz) * sx
out[:, 2, 0] = (xz - wy) * sx
out[:, 0, 1] = (xy - wz) * sy
out[:, 1, 1] = (1 - (xx + zz)) * sy
out[:, 2, 1] = (yz + wx) * sy
out[:, 0, 2] = (xz + wy) * sz
out[:, 1, 2] = (yz - wx) * sz
out[:, 2, 2] = (1 - (xx + yy)) * sz
out[:, 0:3, 3] = translation
out[:, 3, 3] = 1
return out.squeeze(0) if out.shape[0] == 1 else out