import os
import sys
import struct
import xml.etree.ElementTree as xml
import numpy as np
import blueprints as blue
from collections import defaultdict
from itertools import count
from imageio import imread
[docs]
class BaseCache(blue.UniqueThing, blue.CacheType):
"""
:class:`Caches <BaseCache>` are used to store large amounts of data. For blueprints to allow for the
modification of this data, it has to be loaded to memory. If this data is part of a Thing which is copied
multiple times, this would drastically increase the memory usage for redundant data. Caches solve this,
by implementing a single source of data for multiple copies of a Thing. Thing classes that contain large
amounts of data can simply outsource them to a cache (by linking the outsourced properties to the caches
properties) while keeping small attributes that are modified frequently. To ensure data integrity, if a
Things property which is stored in its :class:`Cache <BaseCache>` is modified, a new copy of the caches
content is created replacing the old ``cache`` attribute of the Thing (unless only one Thing uses the
Cache, in which case the Cache is modified directly). For example, consider the following scenario:
.. code-block:: python
:caption: Cache example — The large data attribute is outsourced to a Cache
>>> one = SomeThing(data=open('large.file').read())
>>> one.cache
SomeCache<None:2>
>>> one.data is one.cache.data
True
>>> two = one.copy()
>>> one.cache is two.cache
True
>>> one.name = 'one'
>>> two.name = 'two'
>>> one.cache is two.cache
True
>>> two.data = open('other.file').read()
>>> one.cache is two.cache
False
"""
[docs]
def __init__(self, **kwargs):
"""
Parameters
----------
**kwargs
Keyword arguments are passed to ``super().__init__``.
"""
self._DEPENDENCY_FLAGS = defaultdict(lambda: True)
super().__init__(**kwargs)
def _flag_dependencies(self, attr):
"""
This method updates the flags for deriving attributes, notifying them that their value mus be
updated.
Parameters
----------
attr : str
name of the attribute
"""
if hasattr(self, '_DEPENDENCIES') and hasattr(self, '_DEPENDENCY_FLAGS'):
for dependency in self._DEPENDENCIES[attr]:
self._DEPENDENCY_FLAGS[dependency] = False
def _validate(self, attr):
"""
Indicates whether the attribute has to be updated.
Parameters
----------
attr : str
name of the attribute
"""
return not hasattr(self, '_DEPENDENCY_FLAGS') or self._DEPENDENCY_FLAGS[attr]
[docs]
class MeshCache(blue.MeshCacheType, BaseCache):
"""
This class stores the mesh data which might be loaded from a file and is too large to be copied frequently.
Attributes
----------
built : bool
A flag indicating whether the MeshAssets has been built. This is necessary since multiple
:class:`MeshAssets <blueprints.assets.MeshAsset>` might use the same :class:`MeshCache` in
which case it should be built only once.
centered : bool
If ``True`` the vertecies are normalized such that their mean position is the reference frames
origin.
vertecies : list
The list of vertecies. Each vertex is a np.ndarray with 3 components for each of the spatial
dimensions.
texcoords : list
The list of texture coordinates.
texcoords_idx : dict
A dictionary used to link texture coordinates with face vertecies.
normals : list
The normals used by mujoco, this attribute is derived and should not be used for modification,
see :attr:`face_normals` or :attr:`vertex_normals` for this instead.
face_normals : list
Face normals are the normal vectors of faces. They specify the direction in which the face
points.
vertex_normals : list
Vertex normals are the normal vectors of vertecies. They specify the direction in which the
vertecies of a face point. Specifying vertex normals instead of face normals enables mujoco to
renderer soft edges.
normals_idx : TYPE
A dictionary used to link normals with faces.
faces : list
This attribute is used to write to xml. It returns a flattened list of indecies that is
written raw to xml. For a structured list of faces see :attr:`faces`.
filename : str
The user specified file name.
"""
[docs]
@blue.restrict
def __init__(self,
vertecies: np.ndarray|list[np.ndarray|list[float|int]]|None = None,
faces: list[np.ndarray|list[float|int]]|None = None,
filename: str|None = None,
centered: bool = False,
**kwargs) -> None:
"""
Parameters
----------
vertecies : np.ndarray | list[np.ndarray | list[float | int]] | None, optional
A list of all vertex positions.
faces : list[np.ndarray | list[float | int]] | None, optional
A list of all faces. A face consists of at least 3 indecies of vertecies.
file : str | None, optional
The filename from which the mesh data is loaded and to to which the mesh will be saved.
The file is saved in a special directory such that no input file is overwritten by the
output file.
centered : bool, optional
If ``True`` the vertecies are normalized such that their mean position is the reference
frames origin.
**kwargs
Keyword arguments are passed to ``super().__init__``.
"""
self.centered = centered
self.filename = filename
self.texcoords = None
self.texcoords_idx = None
self.normals = None
self.normals_idx = None
self.face_normals = None
self.vertex_normals = None
self._DEPENDENCIES = {'vertecies': ('vertecies_minimum',
'vertecies_center',
'vertecies_maximum')}
super().__init__(**kwargs)
if filename is not None:
self.load(filename)
elif vertecies is not None:
self.vertecies = vertecies
self.faces = faces
self._built = False
if centered and hasattr(self, '_vertecies'):
self.vertecies = self.vertecies - self.vertecies_center
#blue.REGISTER.caches.append(self)
if 'name' not in kwargs:
self._name = None
[docs]
@blue.restrict
def copy(self, **kwargs) -> blue.CacheType:
"""
This method constructs a copy of the Cache with possible alterations to its
attributes as specified in kwargs.
Parameters
----------
**kwargs
Keyword arguments are passed to the :meth:`__init__` to replace the attributes
of the current Thing from which copy is called.
Returns
-------
blue.CacheType
A new instance of the Cache
"""
mesh = super().copy(**kwargs)
if 'name' not in kwargs:
mesh._name = self._name
return mesh
@blue.restrict
def _build(self, dirname: str, **kwargs) -> None:
"""
This method is called by the Assets parent to construct the xml representations of
the kinematic tree.
Parameters
----------
dirname : str
The directory name in which the mesh file is saved.
**kwargs
Dummy argument
"""
if not self._built:
pathname, basename = os.path.split(self.filename)
if not pathname.endswith(dirname):
path = f'{dirname}/{self.ID}_{basename}'
else:
path = self.filename
self.save(path)
self._path = path
self._built = True
@blue.restrict
@classmethod
def _from_xml_element(cls, xml_element) -> blue.CacheType:
"""
This method reconstructs an MeshCache from an xml element.
Parameters
----------
xml_element : xml.Element
The xml element from which a MeshCache is reconstructed.
Returns
-------
blue.CacheType
The reconstructed MeshCache.
"""
init_args, post_args, rest_args = cls._xml_element_args(xml_element)
if 'vertex' in post_args:
init_args['vertecies'] = post_args['vertex']
del post_args['vertex']
obj = object.__new__(cls)
obj.__init__(**init_args)
for key, val in post_args.items():
setattr(obj, key, val)
return obj
[docs]
@blue.restrict
def load(self, filename: str) -> None:
"""
This methods loads the mesh data from a file.
Parameters
----------
filename : str
Possible file types are ``'.stl'`` binary or ascii and ``'.obj'`` ascii.
"""
with open(filename, 'rb') as file:
data = file.read()
if data.isascii():
self._load_ascii(filename, data.decode('ascii'))
else:
self._load_binary(filename, data)
@blue.restrict
def _load_binary(self,
filename: str,
data: bytes) -> None:
"""
Helper function that routes the loading for difference file formats in binary.
Parameters
----------
filename : str
Possible file types are ``'.stl'``.
data : bytes
The data of the file to be parsed
Raises
------
NotImplemented
If the file format is not supported an error is raised.
"""
if filename.lower().endswith('.obj'):
raise NotImplemented
elif filename.lower().endswith('.stl'):
self._load_STL_binary(data)
else:
raise NotImplemented
@blue.restrict
def _load_ascii(self,
filename: str,
data: str) -> None:
"""
Helper function that routes the loading for difference file formats in ascii.
Parameters
----------
filename : str
Possible file types are ``'.stl'`` and ``'.obj'``.
data : str
The data of the file to be parsed
Raises
------
NotImplemented
If the file format is not supported an error is raised.
"""
#return
if filename.lower().endswith('.obj'):
self._load_OBJ_ascii(data)
elif filename.lower().endswith('.stl'):
self._load_STL_ascii(data)
else:
raise NotImplemented
@blue.restrict
def _load_OBJ_ascii(self, data: str) -> None:
"""
Parses the data from an ascii obj file to the Cache.
Parameters
----------
data : str
The data of the file to be parsed
"""
# DATA CONTAINERS
vertecies = []
faces = []
texcoords = []
face_normals = []
vertex_normals = []
# SORTING CONTAINERS
all_face_normals = {}
texcoords_idx = {}
normals_idx = {}
# READING LINES
for line in data.split('\n'):
# REMOVING COMMENTS
line = line.split('#')[0]
# HANDLING VERTECIES
if line.startswith('v '):
values = line[2:].strip().split(' ')[:3]
vertecies.append(tuple(map(float, values)))
elif line.startswith('vn '):
values = line[3:].strip().split(' ')[:3]
vertex_normals.append(np.array(values, dtype=np.float64))
elif line.startswith('vt '):
values = line[3:].strip().split(' ')[:2]
texcoords.append(tuple(map(float, values)))
elif line.startswith('f '):
vertex_idx = line[2:].strip().split(' ')#[:3]
triangle_idx = [[vertex_idx[0], a, b] for a, b in zip(vertex_idx[1:], vertex_idx[2:])]
for triangle in triangle_idx:
if all(map(lambda x: x.count('//') == 1, triangle)):
# GET VALUES
values, normal_idx = zip(*(value.split('//') for value in triangle))
values = list(map(lambda x: int(x) - 1, values))
normal_idx = list(map(lambda x: int(x) - 1, normal_idx))
# SET INDECIES
normals_idx[len(faces)] = normal_idx
elif all(map(lambda x: x.count('/') == 2, triangle)):
# GET VALUES
values, tex_idx, normal_idx = zip(*(value.split('/') for value in triangle))
values = list(map(lambda x: int(x) - 1, values))
tex_idx = list(map(lambda x: int(x) - 1, tex_idx))
normal_idx = list(map(lambda x: int(x) - 1, normal_idx))
# SET INDECIES
normals_idx[len(faces)] = normal_idx
texcoords_idx[len(faces)] = tex_idx
elif all(map(lambda x: x.count('/') == 1, triangle)):
# GET VALUES
values, tex_idx = zip(*(value.split('/') for value in triangle))
values = list(map(lambda x: int(x) - 1, values))
tex_idx = list(map(lambda x: int(x) - 1, tex_idx))
# SET INDECIES
texcoords_idx[len(faces)] = tex_idx
face = list(map(int, values))
face = [idx if idx != -2 else len(vertecies) for idx in face]
faces.append(face)
assert not texcoords_idx.values() or all(map(texcoords_idx.__contains__, range(len(faces))))
self.vertecies = vertecies
self.faces = faces
self.vertex_normals = vertex_normals or None
self.texcoords = texcoords or None
self.texcoords_idx = texcoords_idx or None
self.normals_idx = normals_idx or None
@blue.restrict
def _load_STL_ascii(self, data: str) -> None:
"""
Parses the data from an ascii stl file to the Cache.
Parameters
----------
data : str
The data of the file to be parsed
"""
# CROP DATA
data = data[data.find('\n'):].split('facet')[1::2]
# INIT CONTAINERS
faces = list()
normals = list()
vertecies = list()
vertex_counter = count()
vertex_table = defaultdict(lambda: next(vertex_counter))
# VECTOR CONSTRUCTION FUNCTION
vector_tuple = lambda s: tuple(map(float, list(filter(lambda x: x != '', s.split(' ')))[:3]))
for facet in data:
# GET FACET SECTION
lines = facet.split('\n')
# GET VECTORS
normal_line = lines[0].replace('normal', '').strip()
vertex_0_line = lines[2].replace('vertex', '').strip()
vertex_1_line = lines[3].replace('vertex', '').strip()
vertex_2_line = lines[4].replace('vertex', '').strip()
# CONSTRUCT VECTOR TUPLES
normal_tuple = vector_tuple(normal_line)
vertex_tuple_0 = vector_tuple(vertex_0_line)
vertex_tuple_1 = vector_tuple(vertex_1_line)
vertex_tuple_2 = vector_tuple(vertex_2_line)
# CONSTRUCT NP.ARRAYS
normal_array = np.array(normal_tuple)
vertex_array_0 = np.array(vertex_tuple_0)
vertex_array_1 = np.array(vertex_tuple_1)
vertex_array_2 = np.array(vertex_tuple_2)
# ADD NEW VERTECIES TO HASH TABLE
if vertex_tuple_0 not in vertex_table:
vertex_table[vertex_tuple_0]
vertecies.append(vertex_array_0)
if vertex_tuple_1 not in vertex_table:
vertex_table[vertex_tuple_1]
vertecies.append(vertex_array_1)
if vertex_tuple_2 not in vertex_table:
vertex_table[vertex_tuple_2]
vertecies.append(vertex_array_2)
# GET NORMAL FROM ORDER
edge_array_0 = vertex_array_1 - vertex_array_0
edge_array_1 = vertex_array_2 - vertex_array_0
edge_cross = np.cross(edge_array_0, edge_array_1)
edge_normal = edge_cross/np.linalg.norm(edge_cross)
correlation = np.dot(edge_normal, normal_array)
if correlation > 0:
vertex_tuples = [vertex_tuple_0, vertex_tuple_1, vertex_tuple_2]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(normal_array)
elif correlation < 0:
vertex_tuples = [vertex_tuple_0, vertex_tuple_2, vertex_tuple_1]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(-normal_array)
else:
vertex_tuples = [vertex_tuple_0, vertex_tuple_2, vertex_tuple_1]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(normal_array)
vertex_tuples = [vertex_tuple_0, vertex_tuple_1, vertex_tuple_2]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(-normal_array)
# SET ATTRIBUTES
self.vertecies = vertecies
self.faces = faces
self.face_normals = normals
@blue.restrict
def _load_STL_binary(self, data: bytes) -> None:
"""
Parses the data from an binary stl file to the Cache.
Parameters
----------
data : bytes
The data of the file to be parsed
"""
# CONVERSION FUNCTIONS
bytes_to_float = lambda x: struct.unpack('f', x)[0]
vector_tuple = lambda x: tuple(map(bytes_to_float, (x[:4], x[4:8], x[8:])))
# HEADER DATA
header = data[:80]
number = struct.unpack('I', data[80:84])[0]
# TRTIANGLE DATA
triangle_data = data[84:]
faces = list()
normals = list()
vertecies = list()
vertex_counter = count()
vertex_table = defaultdict(lambda: next(vertex_counter))
for i in range(number):
# GET TRIANGLE
triangle = triangle_data[i*50:(i+1)*50]
# CONSTRUCT VECTOR TUPLES
normal_tuple = vector_tuple(triangle[:12])
vertex_tuple_0 = vector_tuple(triangle[12:24])
vertex_tuple_1 = vector_tuple(triangle[24:36])
vertex_tuple_2 = vector_tuple(triangle[36:48])
# CONSTRUCT NP.ARRAYS
normal_array = np.array(normal_tuple)
vertex_array_0 = np.array(vertex_tuple_0)
vertex_array_1 = np.array(vertex_tuple_1)
vertex_array_2 = np.array(vertex_tuple_2)
# ADD NEW VERTECIES TO HASH TABLE
if vertex_tuple_0 not in vertex_table:
vertex_table[vertex_tuple_0]
vertecies.append(vertex_array_0)
if vertex_tuple_1 not in vertex_table:
vertex_table[vertex_tuple_1]
vertecies.append(vertex_array_1)
if vertex_tuple_2 not in vertex_table:
vertex_table[vertex_tuple_2]
vertecies.append(vertex_array_2)
# GET NORMAL FROM ORDER
edge_array_0 = vertex_array_1 - vertex_array_0
edge_array_1 = vertex_array_2 - vertex_array_0
edge_cross = np.cross(edge_array_0, edge_array_1)
edge_normal = edge_cross/np.linalg.norm(edge_cross)
correlation = np.dot(edge_normal, normal_array)
if correlation > 0:
vertex_tuples = [vertex_tuple_0, vertex_tuple_1, vertex_tuple_2]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(normal_array)
elif correlation < 0:
vertex_tuples = [vertex_tuple_0, vertex_tuple_2, vertex_tuple_1]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(-normal_array)
else:
vertex_tuples = [vertex_tuple_0, vertex_tuple_2, vertex_tuple_1]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(normal_array)
vertex_tuples = [vertex_tuple_0, vertex_tuple_1, vertex_tuple_2]
face = list(map(vertex_table.__getitem__, vertex_tuples))
faces.append(face)
normals.append(-normal_array)
# SET ATTRIBUTES
self.vertecies = vertecies
self.faces = faces
self.face_normals = normals
[docs]
@blue.restrict
def save(self, file: str) -> None:
"""
This method saves the Mesh data to a file.
Parameters
----------
file : str
The name to which the file is saved.
Raises
------
NotImplemented
If the file format is not supported an error is raised.
"""
if file.lower().endswith('.obj'):
self._save_OBJ(file)
elif file.lower().endswith('.stl'):
self._save_STL(file)
elif file.lower().endswith('.msh'):
raise NotImplemented
else:
raise NotImplemented
@blue.restrict
def _save_OBJ(self, filename: str|None) -> None:
"""
This method saves the Mesh data to an ascii obj file.
Parameters
----------
filename : str | None
The name to which the file is saved.
"""
lines = ['# Exported with microcosm AI blueprints\n', '\n# VERTECIES\n']
for v in self.vertecies:
lines.append(f'v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n')
if self.vertex_normals:
lines.append('\n# NORMALS\n')
for n in self.vertex_normals:
lines.append(f'vn {n[0]:.6f} {n[1]:.6f} {n[2]:.6f}\n')
if self.texcoords:
lines.append('\n# TEXTURE COORDINATES\n')
for t in self.texcoords:
lines.append(f'vt {t[0]:.6f} {t[1]:.6f}\n')
if self.faces:
texcoords_idx = self.texcoords_idx
normals_idx = self.normals_idx
lines.append('\n# FACES\n')
for i, face in enumerate(self.faces):
has_tex = i in texcoords_idx
has_nrm = i in normals_idx
if has_tex and has_nrm:
parts = ' '.join(f"{v + 1}/{t + 1}/{n + 1}" for v, t, n in zip(face, texcoords_idx[i], normals_idx[i]))
elif has_tex:
parts = ' '.join(f"{v + 1}/{t + 1}" for v, t in zip(face, texcoords_idx[i]))
elif has_nrm:
parts = ' '.join(f"{v + 1}//{n + 1}" for v, n in zip(face, normals_idx[i]))
else:
parts = ' '.join(f"{v + 1}" for v in face)
lines.append(f'f {parts}\n')
with open(filename, 'w') as file:
file.writelines(lines)
@blue.restrict
def _save_STL(self, filename: str) -> None:
"""
This method saves the Mesh data to an binary stl file.
Parameters
----------
filename : str
The name to which the file is saved.
"""
# GET VERTECIES AND CONSTRUCT CONVERSION FUNCTION
vertecies = self.vertecies
float_to_bytes = lambda x: struct.pack('f', x)
vector_bytes = lambda x: float_to_bytes(x[0]) + float_to_bytes(x[1]) + float_to_bytes(x[2])
# CREATE HEADER
SOURCE = bytes('Saved from Blueprints, UNITS= m', encoding='ascii')
HEADER = bytearray(84)
HEADER[:len(SOURCE)] = SOURCE
HEADER[80:] = struct.pack('I', len(self.faces))
with open(filename, 'wb') as file:
file.write(HEADER)
for normal, indecies in zip(self.face_normals, self.faces):
vertex_a, vertex_b, vertex_c = map(vertecies.__getitem__, indecies)
file.write(vector_bytes(normal))
file.write(vector_bytes(vertex_a))
file.write(vector_bytes(vertex_b))
file.write(vector_bytes(vertex_c))
file.write(bytes.fromhex('00') * 2)
# MUJOCO PROPERTIES
@property
def file(self) -> str | None:
"""
Filename for Mujoco XML
Returns
-------
str
"""
return self.filename
@property
def vertex(self):
"""
Returns
-------
np.ndarray
This attribute is used to write construct the mujoco xml. It returns a flattened list of
coordinates that is written raw to xml. For a structured list of vertecies see :attr:`vertecies`.
"""
return self._vertecies.flatten()
@property
def face(self) -> np.ndarray|None:
"""
Returns
-------
np.ndarray | None
This attribute is used to write construct the mujoco xml. It returns a flattened list of
coordinates that is written raw to xml. For a structured list of faces see :attr:`faces`.
"""
return self._faces
@face.setter
@blue.restrict
def face(self, face: list) -> None:
"""
This attribute is used to write construct the mujoco xml. It returns a flattened list of
coordinates that is written raw to xml. For a structured list of faces see :attr:`faces`.
Parameters
----------
face : list
A flattened list of all face indecies.
"""
self.faces = face
@property
def texcoord(self) -> np.ndarray|None:
"""
Returns
-------
np.ndarray | None
This attribute is used to write to xml. It returns a flattened list of texture coordinates that
is written raw to xml. For a structured list of texture coordinates see :attr:`texcoords`.
"""
return self._texcoords or None
@texcoord.setter
@blue.restrict
def texcoord(self, texcoord: list) -> None:
"""
This attribute is used to write to xml. It returns a flattened list of texture coordinates that
is written raw to xml. For a structured list of texture coordinates see :attr:`texcoords`.
Parameters
----------
texcoord : list
A flattened list of all texture coordinates.
"""
self.texcoords = texcoord
@property
def normal(self) -> np.ndarray|None:
"""
Returns
-------
np.ndarray | None
This attribute is used to write to xml. It returns a flattened list of face normals that
is written raw to xml. For a structured list of face normals see :attr:`normals`.
"""
if self._face_normals is None:
return None
else:
return np.concatenate(self._face_normals)
@normal.setter
@blue.restrict
def normal(self, normal: list) -> None:
"""
This attribute is used to write to xml. It returns a flattened list of face normals that is
written raw to xml. For a structured list of face normals see :attr:`normals`.
Parameters
----------
normal : list
A flattened list of all normals coordinates.
"""
self.face_normals = normal
@property
def size(self) -> np.ndarray:
"""
Returns
-------
np.ndarray
The size of the Mesh is computed as the difference of the minimal and maximal component
for each axis on each vertex.
"""
return self.vertecies_max - self.vertecies_min
@size.setter
@blue.restrict
def size(self, size: np.ndarray|list[int|float]) -> None:
"""
The size of the Mesh is defined as the difference of the minimal and maximal component for each
axis on each vertex.
Parameters
----------
size : np.ndarray | list[int | float]
Setting this attribute results in a rescaling of all vertecies. Setting this attribute
should only be done if the raw data must be modified directly, use :attr:`scale <blueprints.assets.MeshAsset.scale>`
instead.
"""
vertecies = np.array(self.vertecies)
vertecies = vertecies - self.vertecies_center
scale = np.array(size) / self.size
scale[np.isinf(scale)] = 0
vertecies = vertecies * scale
self.vertecies = vertecies + self.vertecies_center
# BLUEPRINTS PROPERTIES
@property
def vertecies(self) -> np.ndarray:
"""
Returns
-------
np.ndarray
The list of vertecies. Each vertex is a np.ndarray with 3 components for each of the
spatial dimensions.
"""
return self._vertecies
@vertecies.setter
@blue.restrict
def vertecies(self, vertecies: np.ndarray|list[np.ndarray|list[float|int]]) -> None:
"""
Parameters
----------
vertecies : np.ndarray | list[np.ndarray | list[float | int]]
The list of vertecies. Each vertex is a np.ndarray with 3 components for each of the
spatial dimensions.
"""
vertecies = np.array(vertecies)
self._vertecies = vertecies
self._flag_dependencies('vertecies')
self._built = False
@property
def faces(self) -> list[np.ndarray]|None:
"""
Returns
-------
list[np.ndarray] | None
The list of faces. Each face is a np.ndarray with n indecies for each of the vertecies
of the face.
"""
return self._faces
####
if self._faces is None:
return None
else:
return list(np.rollaxis(self._faces.reshape((-1, 3)), axis=0))
@faces.setter
@blue.restrict
def faces(self, faces: np.ndarray|list[np.ndarray|list[float|int]]|None) -> None:
"""
Parameters
----------
faces : np.ndarray | list[np.ndarray | list[float | int]] | None
The list of faces. Each face is a np.ndarray with n indecies for each of the vertecies
of the face.
"""
if isinstance(faces, np.ndarray):
faces = list(np.rollaxis(faces, axis=0))
self._faces = faces
self._built = False
return
###
if faces is not None:
faces = np.array(faces, dtype=np.int32).flatten()
self._faces = faces
self._built = False
@property
def texcoords(self) -> list[np.ndarray]|None:
"""
Returns
-------
list[np.ndarray] | None
The list of texture coordinates. Each texture coordinate is a np.ndarray with
coordinates in the range [0, 1] specifying the point on the texture which is mapped to
the referencing face vertex.
"""
if self._texcoords is None:
return None
else:
return list(np.rollaxis(self._texcoords.reshape((-1, 2)), axis=0))
@texcoords.setter
@blue.restrict
def texcoords(self, texcoords: np.ndarray|list[np.ndarray|list[float|int]]|None) -> None:
"""
Parameters
----------
texcoords : np.ndarray | list[np.ndarray | list[float | int]] | None
The list of texture coordinates. Each texture coordinate is a np.ndarray with
coordinates in the range [0, 1] specifying the point on the texture which is mapped to
the referencing face vertex.
"""
if texcoords is not None:
texcoords = np.array(texcoords, dtype=np.float32).flatten()
self._texcoords = texcoords
self._built = False
@property
def texcoords_idx(self) -> dict|None:
"""
Returns
-------
dict | None
The list of texture coordinate indecies for faces.
"""
return self._texcoords_idx
@texcoords_idx.setter
@blue.restrict
def texcoords_idx(self, texcoords_idx: dict|None) -> None:
"""
Parameters
----------
texcoords_idx : dict | None
The list of texture coordinate indecies for faces.
"""
self._texcoords_idx = texcoords_idx
self._built = False
@property
def face_normals(self) -> list[np.ndarray]|None:
"""
Returns
-------
list[np.ndarray] | None
Face normals are the normal vectors of faces. They specify the direction in which the
face points.
"""
if self._face_normals is None:
if self._faces is not None:
faces = self.vertecies[self.faces,:]
edges = faces[:,:,1:] - faces[:,:,:1]
cross = np.cross(edges[...,0], edges[...,1], axisa=1, axisb=1)
norms = cross / np.linalg.norm(cross, axis=1)[:,None]
self.face_normals = norms
return self.face_normals
else:
return None
else:
return self._face_normals
@face_normals.setter
@blue.restrict
def face_normals(self, face_normals: np.ndarray|list[np.ndarray|list[float|int]]|None) -> None:
"""
Parameters
----------
face_normals : np.ndarray | list[np.ndarray | list[float | int]] | None
Face normals are the normal vectors of faces. They specify the direction in which the
face points.
"""
if isinstance(face_normals, np.ndarray):
face_normals = list(np.rollaxis(face_normals, axis=0))
self._face_normals = face_normals
self._built = False
@property
def vertex_normals(self) -> list[np.ndarray]|None:
"""
Returns
-------
list[np.ndarray] | None
Vertex normals are the normal vectors of vertecies. They specify the direction in which
the vertecies of a face point. Specifying vertex normals instead of face normals enables
mujoco torenderer soft edges.
"""
if self._vertex_normals is None:
return None
else:
return self._vertex_normals
@vertex_normals.setter
@blue.restrict
def vertex_normals(self, vertex_normals: np.ndarray|list[np.ndarray|list[float|int]]|None) -> None:
"""
Parameters
----------
vertex_normals : np.ndarray | list[np.ndarray | list[float | int]] | None
Vertex normals are the normal vectors of vertecies. They specify the direction in which
the vertecies of a face point. Specifying vertex normals instead of face normals enables
mujoco torenderer soft edges.
"""
if isinstance(vertex_normals, np.ndarray):
vertex_normals = list(np.rollaxis(vertex_normals, axis=0))
self._vertex_normals = vertex_normals
self._built = False
@property
def normals_idx(self) -> dict|None:
"""
The list of normals indecies for faces.
Returns
-------
dict | None
"""
return self._normals_idx
@normals_idx.setter
@blue.restrict
def normals_idx(self, normals_idx: dict|None) -> None:
"""
Parameters
----------
normals_idx : dict | None
The list of normals indecies for faces.
"""
self._normals_idx = normals_idx
self._built = False
@property
def vertecies_min(self):
"""
The minimum for each axis component of all vertecies.
Returns
-------
np.ndarray
"""
if self._validate('vertecies_min'):
self._vertecies_min = np.min(self.vertecies, axis=0)
self._DEPENDENCY_FLAGS['vertecies_min'] = True
return self._vertecies_min
@property
def vertecies_max(self):
"""
The maximum for each axis component of all vertecies.
Returns
-------
np.ndarray
"""
if self._validate('vertecies_max'):
self._vertecies_max = np.max(self.vertecies, axis=0)
self._DEPENDENCY_FLAGS['vertecies_max'] = True
return self._vertecies_max
@property
def vertecies_center(self):
"""
The middle point for each axis component of all vertecies.
Returns
-------
np.ndarray
"""
return (self.vertecies_min + self.vertecies_max)/2
[docs]
class HFieldCache(blue.HFieldCacheType, BaseCache):
[docs]
def __init__(self,
terrain: np.ndarray|list[np.ndarray|list[float|int]]|None = None,
filename: str|None = None,
**kwargs):
self._built = False
self._world = None
super().__init__(**kwargs)
self.filename = filename
if filename is not None:
self.load(filename)
elif terrain is not None:
self.terrain = terrain
if 'name' not in kwargs:
self._name = None
def __getitem__(self,
key: tuple[slice|int]) -> np.ndarray|np.float32:
"""
Returns
-------
np.ndarray | np.float32
The values from the selected index/slice of the height field.
"""
return self.terrain[key]
def __setitem__(self,
key: tuple[slice|int],
value: int|float|list[int|float]|np.ndarray) -> None:
"""
Parameters
----------
key : tuple[slice | int]
The indecies/slices of acces
value : int|float|list[int|float]|np.ndarray
The value to be assigned in the selected parts of the field.
"""
self.terrain[key] = np.array(value, dtype=np.float32)
[docs]
@blue.restrict
def copy(self, **kwargs) -> blue.CacheType:
"""
This method constructs a copy of the Cache with possible alterations to its
attributes as specified in kwargs.
Parameters
----------
**kwargs
Keyword arguments are passed to the :meth:`__init__` to replace the attributes
of the current Thing from which copy is called.
Returns
-------
blue.CacheType
A new instance of the Cache
"""
mesh = super().copy(**kwargs)
if 'name' not in kwargs:
mesh._name = self._name
return mesh
@blue.restrict
def _build(self, dirname: str, **kwargs) -> None:
"""
This method is called by the Assets parent to construct the xml representations of
the kinematic tree.
Parameters
----------
dirname : str
The directory name in which the hfield file is saved.
**kwargs
Dummy argument
"""
if not self._built:
filename = self.filename or f'hfield.hf'
pathname, basename = os.path.split(filename)
if not pathname.endswith(dirname):
path = f'{dirname}/{self.ID}_{basename}'
else:
path = filename
self.save(path)
self._path = path
self._built = True
@blue.restrict
@classmethod
def _from_xml_element(cls, xml_element) -> blue.CacheType:
"""
This method reconstructs an HFieldCache from an xml element.
Parameters
----------
xml_element : xml.Element
The xml element from which a HFieldCache is reconstructed.
Returns
-------
blue.CacheType
The reconstructed HFieldCache.
"""
init_args, post_args, rest_args = cls._xml_element_args(xml_element)
if 'terrain' in post_args:
init_args['terrain'] = post_args['terrain']
del post_args['terrain']
if 'file' in rest_args:
init_args['filename'] = rest_args['file']
obj = object.__new__(cls)
obj.__init__(**init_args)
for key, val in post_args.items():
setattr(obj, key, val)
return obj
[docs]
@blue.restrict
def load(self, filename: str) -> None:
"""
This methods loads the mesh data from a file.
Parameters
----------
filename : str
Possible file types are ``'.stl'`` binary or ascii and ``'.obj'`` ascii.
"""
if filename.lower().endswith('.hf'):
with open(filename, 'rb') as file:
data = file.read()
self._load_HF(data)
elif filename.lower().endswith('.png'):
self._load_PNG(filename)
else:
raise Exception('HFields are only implemented for PNG and HF.')
@blue.restrict
def _load_HF(self, data: bytes) -> None:
"""
Parses the data from an binary stl file to the Cache.
Parameters
----------
data : bytes
The data of the file to be parsed
"""
# HEADER DATA
nrows = struct.unpack('I', data[0:4])[0]
ncols = struct.unpack('I', data[4:8])[0]
# HEIGHT DATA (batch unpack all floats at once)
n_floats = nrows * ncols
heights = struct.unpack(f'{n_floats}f', data[8:8 + n_floats * 4])
# SET ATTRIBUTES
self.terrain = np.array(heights, dtype=np.float32).reshape((nrows, ncols))
@blue.restrict
def _load_PNG(self, filename: str) -> None:
image = imread(filename)
height = np.mean(image[:,:,:3], axis=2)
self.terrain = height
[docs]
@blue.restrict
def save(self, filename: str) -> None:
"""
This method saves the Mesh data to a file.
Parameters
----------
file : str
The name to which the file is saved.
Raises
------
NotImplemented
If the file format is not supported an error is raised.
"""
if filename.lower().endswith('.hf'):
self._save_HF(filename)
else:
raise NotImplemented
@blue.restrict
def _save_HF(self, filename: str) -> None:
"""
This method saves the Mesh data to an binary stl file.
Parameters
----------
filename : str
The name to which the file is saved.
"""
# GET VERTECIES AND CONSTRUCT CONVERSION FUNCTION
int_to_bytes = lambda x: struct.pack('I', x)
float_to_bytes = lambda x: struct.pack('f', x)
with open(filename, 'wb') as file:
file.write(int_to_bytes(self.nrow))
file.write(int_to_bytes(self.ncol))
for height in self.terrain.reshape(-1):
file.write(float_to_bytes(height))
# DERIVED PROPERTIES
@property
def elevation(self):
"""
Derived dummy property
"""
return self.terrain.flatten()
@property
def nrow(self) -> int:
"""
Number of rows in the terrain.
Returns
-------
int
"""
return int(self.terrain.shape[0])
@property
def ncol(self) -> int:
"""
Number of collumns in the terrain.
Returns
-------
int
"""
return int(self.terrain.shape[1])
# BLUEPRINTS PROPERTIES
@property
def filename(self) -> str:
"""
The filename of the hfield data.
Returns
-------
str
"""
return self._filename
@filename.setter
@blue.restrict
def filename(self, filename: str|None) -> None:
self._filename = filename
@property
def terrain(self) -> np.ndarray:
"""
Returns
-------
np.ndarray
The list of vertecies. Each vertex is a np.ndarray with 3 components for each of the
spatial dimensions.
"""
return self._terrain
@terrain.setter
@blue.restrict
def terrain(self, terrain: np.ndarray|list[np.ndarray|list[float|int]]) -> None:
"""
Parameters
----------
vertecies : np.ndarray | list[np.ndarray | list[float | int]]
The list of vertecies. Each vertex is a np.ndarray with 3 components for each of the
spatial dimensions.
"""
self._terrain = np.array(terrain, dtype=np.float32)
if self._world is not None:
mj_hfield = self._world._mj_data.model.hfield(self._index)
mj_hfield.data = self._terrain
if self._world._viewer is not None:
self._world._viewer.update_hfield(self._index)
#self._world._mj_model.update_hfield(self._index)
@property
def file(self) -> str:
"""
Dummy variable for the default names of hfield data files.
Returns
-------
str
"""
if self._file is None:
return f'hfield_{self.name}.hf'
else:
return self._file
CACHE_THINGS = {'mesh': MeshCache,
'hfield': HFieldCache}