"""gustaf/gustaf/vertices.py.
Vertices. Base of all "Mesh" geometries.
"""
import copy
import numpy as np
from gustaf import helpers, settings, show, utils
from gustaf._base import GustafBase
from gustaf.helpers.options import Option
[docs]
class VerticesShowOption(helpers.options.ShowOption):
"""
Show options for vertices.
"""
_valid_options = helpers.options.make_valid_options(
*helpers.options.vedo_common_options,
Option("vedo", "r", "Radius of vertices in units of pixels.", (int,)),
Option(
"vedo",
"labels",
"Places a label/description str at the place of vertices.",
(np.ndarray, tuple, list),
),
Option(
"vedo",
"label_options",
"Label kwargs to be passed during initialization."
"Valid keywords are: {scale: float, xrot: float, yrot: float, "
"zrot: float, ratio: float, precision: int, italic: bool, "
"font: str, justify: str, c: (str, tuple, list, int), "
"alpha: float}. "
"As further hint, justify takes '-' joined combination of "
"{center, mid, right, left, top, bottom}.",
(dict,),
),
)
_helps = "Vertices"
def _initialize_vedo_showable(self):
"""
Initialize Vertices showable for vedo.
Parameters
----------
None
Returns
-------
vertices: vedo.Points
"""
init_options = ("r",)
vertices = show.vedo.Points(
self._helpee.const_vertices, **self[init_options]
)
labels = self.get("labels", None)
if labels is not None:
# check length
if len(labels) != len(self._helpee.const_vertices):
raise ValueError(
f"number of label contents ({len(labels)}) and "
"number of vertices"
f"({len(self._helpee.const_vertices)}) does not match."
)
# apply options and return labels
return vertices.labels(
content=labels,
on="points",
**self.get("label_options", dict()),
)
else:
# no labels, return Points
return vertices
[docs]
class Vertices(GustafBase):
kind = "vertex"
__slots__ = (
"_vertices",
"_const_vertices",
"_computed",
"_show_options",
"_setter_copies",
"_vertex_data",
)
# define frequently used types as dunder variable
__show_option__ = VerticesShowOption
def __init__(
self,
vertices=None,
copy=True,
):
"""Vertices. It has vertices.
Parameters
-----------
vertices: (n, d) np.ndarray
copy: bool
If false, all setter calls will try to avoid copy.
Returns
--------
None
"""
# call setters
self.setter_copies = copy
self.vertices = vertices
# init helpers
self._vertex_data = helpers.data.VertexData(self)
self._computed = helpers.data.ComputedMeshData(self)
self._show_options = self.__show_option__(self)
@property
def vertices(self):
"""Returns vertices.
Parameters
-----------
None
Returns
--------
vertices: (n, d) np.ndarray
"""
self._logd("returning vertices")
return self._vertices
@vertices.setter
def vertices(self, vs):
"""Vertices setter. This will saved as a tracked array. This tracked
array is very sensitive and if we do anything with it that may hint an
inplace operation, it will be marked as modified. This includes copying
and slicing. If you know you aren't going to modify the array, please
consider using `const_vertices`. Somewhat c-style hint in naming.
Parameters
-----------
vs: (n, d) np.ndarray
Returns
--------
None
"""
self._logd("setting vertices")
self._vertices = helpers.data.make_tracked_array(
vs, settings.FLOAT_DTYPE, self.setter_copies
)
# shape check
if self._vertices.size > 0:
utils.arr.is_shape(self._vertices, (-1, -1), strict=True)
# exact same, but not tracked.
self._const_vertices = self._vertices.view()
self._const_vertices.flags.writeable = False
# at each setting, validate vertex_data
# --> by len mismatch, will clear data
if hasattr(self, "vertex_data"):
self.vertex_data._validate_len(raise_=False)
@property
def const_vertices(self):
"""Returns non-mutable view of `vertices`. Naming inspired by c/cpp
sessions.
Parameters
-----------
None
Returns
--------
None
"""
self._logd("returning const_vertices")
return self._const_vertices
@property
def vertex_data(self):
"""
Returns vertex_data manager. Behaves similar to dict() and can be used
to store values/data associated with each vertex.
Parameters
----------
None
Returns
-------
vertex_data: VertexData
"""
self._logd("returning vertex_data")
return self._vertex_data
@property
def setter_copies(self):
"""
Switch to set if setter should copy or try to avoid copying.
If data is np.ndarray, c_contiguous, and has same dtype as
corresponding settings.<FLOAT/INT>_dtype, it can probably avoid being
copied. Setter will try to cast input to bool.
Parameters
----------
None
Returns
-------
setter_copies: bool
"""
return self._setter_copies
@setter_copies.setter
def setter_copies(self, should_copy):
"""
Sets setter_copies
Parameters
----------
should_copy: bool
Returns
-------
None
"""
self._setter_copies = bool(should_copy)
@property
def show_options(self):
"""
Returns a show option manager for this object. Behaves similar to
dict.
Parameters
----------
None
Returns
-------
show_options: ShowOption
A derived class that's suitable for current class.
"""
self._logd("returning show_options")
return self._show_options
@property
def vis_dict(self):
"""
Temporary backward compatibility
"""
self._logw("`vis_dict` is deprecated. Please use `show_options`")
return self.show_options
@vis_dict.setter
def vis_dict(self, vd):
"""
Tmp
"""
self._logw("`vis_dict` is deprecated. Please use `show_options`")
self._show_options = vd
@property
def whatami(self):
"""Answers deep philosophical question: "what am i"?
Parameters
----------
None
Returns
--------
whatami: str
vertices
"""
return "vertices"
[docs]
@helpers.data.ComputedMeshData.depends_on(["vertices"])
def unique_vertices(self, tolerance=None, **kwargs):
"""Returns a namedtuple that holds unique vertices info. Unique here
means "close-enough-within-tolerance".
Parameters
-----------
tolerance: float
(Optional) Default is settings.TOLERANCE
recompute: bool
Only applicable as keyword argument. Force re-computes.
Returns
--------
unique_vertices_info: Unique2DFloats
namedtuple with `values`, `ids`, `inverse`, `intersection`.
"""
self._logd("computing unique vertices")
if tolerance is None:
tolerance = settings.TOLERANCE
values, ids, inverse, intersection = utils.arr.close_rows(
self.const_vertices, tolerance=tolerance, **kwargs
)
return helpers.data.Unique2DFloats(
values,
ids,
inverse,
intersection,
)
[docs]
@helpers.data.ComputedMeshData.depends_on(["vertices"])
def bounds(self):
"""Returns bounds of the vertices. Bounds means AABB of the geometry.
Parameters
-----------
None
Returns
--------
bounds: (d,) np.ndarray
"""
self._logd("computing bounds")
return utils.arr.bounds(self.const_vertices)
[docs]
@helpers.data.ComputedMeshData.depends_on(["vertices"])
def bounds_diagonal(self):
"""Returns diagonal vector of the bounding box.
Parameters
-----------
None
Returns
--------
bounds_diagonal: (d,) np.ndarray
same as `bounds[1] - bounds[0]`
"""
self._logd("computing bounds_diagonal")
bounds = self.bounds()
return bounds[1] - bounds[0]
[docs]
@helpers.data.ComputedMeshData.depends_on(["vertices"])
def bounds_diagonal_norm(self):
"""Returns norm of bounds diagonal.
Parameters
-----------
None
Returns
--------
bounds_diagonal_norm: float
"""
self._logd("computing bounds_diagonal_norm")
return float(sum(self.bounds_diagonal() ** 2) ** 0.5)
[docs]
def update_vertices(self, mask, inverse=None):
"""Update vertices with a mask. In other words, keeps only masked
vertices. Adapted from `github.com/mikedh/trimesh`. Updates
connectivity accordingly too.
Parameters
-----------
mask: (n,) bool or int
inverse: (len(self.vertices),) int
Returns
--------
updated_self: type(self)
"""
vertices = self.const_vertices.copy()
# make mask numpy array
mask = np.asarray(mask)
if (mask.dtype.name == "bool" and mask.all()) or len(mask) == 0:
return self
# create inverse mask if not passed
check_neg = False
if inverse is None and self.kind != "vertex":
inverse = np.full(len(vertices), -11, dtype=settings.INT_DTYPE)
check_neg = True
if mask.dtype.kind == "b":
inverse[mask] = np.arange(mask.sum())
elif mask.dtype.kind == "i":
inverse[mask] = np.arange(len(mask))
else:
inverse = None
# re-index elements from inverse
# TODO: Here could be a good place to preserve BCs.
elements = None
if inverse is not None and self.kind != "vertex":
elements = self.const_elements.copy()
elements = inverse[elements.reshape(-1)].reshape(
(-1, elements.shape[1])
)
# remove all the elements that's not part of inverse
if check_neg:
elem_mask = (elements > -1).all(axis=1)
elements = elements[elem_mask]
# apply mask
vertices = vertices[mask]
def update_vertex_data(obj, m, vertex_data):
"""apply mask to vertex data if there's any."""
new_data = helpers.data.VertexData(obj)
for key, values in vertex_data.items():
# should work, since this is called after updating vertices
new_data[key] = values[m]
obj._vertex_data = new_data
return obj
# make shallow copy of saved vertex data
v_data = self.vertex_data._saved.copy()
# update - if number of vertices changes, this will remove all the
# length mis-matching data.
self.vertices = vertices
if elements is not None:
self.elements = elements
update_vertex_data(self, mask, v_data)
return self
[docs]
def select_vertices(self, ranges):
"""Returns vertices inside the given range.
Parameters
-----------
ranges: (d, 2) array-like
Takes None.
Returns
--------
ids: (n,) np.ndarray
"""
return utils.arr.select_with_ranges(self.vertices, ranges)
[docs]
def remove_vertices(self, ids):
"""Removes vertices with given vertex ids.
Parameters
-----------
ids: (n,) np.ndarray
Returns
--------
new_self: type(self)
"""
mask = np.ones(len(self.vertices), dtype=bool)
mask[ids] = False
return self.update_vertices(mask)
[docs]
def merge_vertices(self, tolerance=None, **kwargs):
"""Based on unique vertices, merge vertices if it is mergeable.
Parameters
-----------
tolerance: float
Default is settings.TOLERANCE
Returns
--------
merged_self: type(self)
"""
unique_vs = self.unique_vertices(tolerance, **kwargs)
self._logd("number of vertices")
self._logd(f" before merge: {len(self.vertices)}")
self._logd(f" after merge: {len(unique_vs.ids)}")
return self.update_vertices(
mask=unique_vs.ids,
inverse=unique_vs.inverse,
)
[docs]
def showable(self, **kwargs):
"""Returns showable object, meaning object of visualization backend.
Parameters
-----------
**kwargs:
Returns
--------
showable: obj
Obj of `gustaf.settings.VISUALIZATION_BACKEND`
"""
return show.make_showable(self, **kwargs)
[docs]
def show(self, **kwargs):
"""Show current object using visualization backend.
Parameters
-----------
**kwargs:
Returns
--------
None
"""
return show.show(self, **kwargs)
[docs]
def copy(self):
"""Returns deepcopy of self.
Parameters
-----------
None
Returns
--------
self_copy: type(self)
"""
# all attributes are deepcopy-able
return copy.deepcopy(self)
[docs]
@classmethod
def concat(cls, *instances):
"""Sequentially put them together to make one object.
Parameters
-----------
*instances: List[type(cls)]
Allows one iterable object also.
Returns
--------
one_instance: type(cls)
"""
def is_concatable(inst):
"""Return true, if it is same as type(cls)"""
if isinstance(inst, cls):
return True
else:
return False
# If only one instance is given and it is iterable, adjust
# so that we will just iterate that.
if (
len(instances) == 1
and not isinstance(instances[0], str)
and hasattr(instances[0], "__iter__")
):
instances = instances[0]
vertices = []
has_elem = cls.kind != "vertex"
if has_elem:
elements = []
# check if everything is "concatable".
for ins in instances:
if not is_concatable(ins):
raise TypeError(
"Can't concat. One of the instances is not "
f"`{cls.__name__}`."
)
tmp_ins = ins.copy()
# make sure each element index starts from 0 & end at len(vertices)
if has_elem:
tmp_ins.remove_unreferenced_vertices()
vertices.append(tmp_ins.vertices)
if has_elem:
if len(elements) == 0:
elements.append(tmp_ins.elements)
e_offset = elements[-1].max() + 1
else:
elements.append(tmp_ins.elements + e_offset)
e_offset = elements[-1].max() + 1
if has_elem:
return cls(
vertices=np.vstack(vertices),
elements=np.vstack(elements),
)
else:
return Vertices(vertices=np.vstack(vertices))
def __add__(self, to_add):
"""Concat in form of +.
Parameters
-----------
to_add: type(self)
Returns
--------
added: type(self)
"""
return type(self).concat(self, to_add)