import os
import pickle as pkl
import fast_simplification
import numpy as np
import numpy.typing as npt
import trimesh
import genesis as gs
import genesis.utils.mesh as mu
import genesis.utils.gltf as gltf_utils
import genesis.utils.particle as pu
from genesis.options.surfaces import Surface
from genesis.repr_base import RBC
from genesis.utils.misc import redirect_libc_stderr
[docs]
class Mesh(RBC):
"""
Genesis's own triangle mesh object.
This is a wrapper of `trimesh.Trimesh` with some additional features and attributes. The internal trimesh object can be accessed via `self.trimesh`.
We perform both convexification and decimation to preprocess the mesh for simulation if specified.
Parameters
----------
surface : genesis.Surface
The mesh's surface object.
uvs : np.ndarray
The mesh's uv coordinates.
convexify : bool
Whether to convexify the mesh.
decimate : bool
Whether to decimate the mesh.
decimate_face_num : int
The target number of faces after decimation.
decimate_aggressiveness : int
How hard the decimation process will try to match the target number of faces, as a integer ranging from 0 to 8.
0 is losseless. 2 preserves all features of the original geometry. 5 may significantly alters
the original geometry if necessary. 8 does what needs to be done at all costs. Default to 0.
metadata : dict
The metadata of the mesh.
"""
def __init__(
self,
mesh,
surface: Surface | None = None,
uvs: npt.NDArray | None = None,
convexify=False,
decimate=False,
decimate_face_num=500,
decimate_aggressiveness=0,
metadata=None,
):
self._uid = gs.UID()
self._mesh = mesh
self._surface = surface
self._uvs = uvs
self._metadata = metadata or {}
self._color = np.array([1.0, 1.0, 1.0, 1.0], dtype=gs.np_float)
if self._surface.requires_uv(): # check uvs here
if self._uvs is None:
if "mesh_path" in metadata:
gs.logger.warning(
f"Texture given but asset missing uv info (or failed to load): {metadata['mesh_path']}"
)
else:
gs.logger.warning("Texture given but asset missing uv info (or failed to load).")
else:
self._uvs = None
if convexify:
self.convexify()
if decimate:
self.decimate(decimate_face_num, decimate_aggressiveness, convexify)
[docs]
def convexify(self):
"""
Convexify the mesh.
"""
if self._mesh.vertices.shape[0] > 3:
self._mesh = trimesh.convex.convex_hull(self._mesh)
self._metadata["convexified"] = True
self.clear_visuals()
[docs]
def decimate(self, decimate_face_num, decimate_aggressiveness, convexify):
"""
Decimate the mesh.
"""
if self._mesh.vertices.shape[0] > 3 and len(self._mesh.faces) > decimate_face_num:
self._mesh.process(validate=True)
self._mesh = trimesh.Trimesh(
*fast_simplification.simplify(
self._mesh.vertices,
self._mesh.faces,
target_count=decimate_face_num,
agg=decimate_aggressiveness,
lossless=(decimate_aggressiveness == 0),
),
)
self._metadata["decimated"] = True
# need to run convexify again after decimation, because sometimes decimating a convex-mesh can make it non-convex...
if convexify:
self.convexify()
self.clear_visuals()
[docs]
def remesh(self, edge_len_abs=None, edge_len_ratio=0.01, fix=True):
"""
Remesh for tetrahedralization.
"""
rm_file_path = mu.get_remesh_path(self.verts, self.faces, edge_len_abs, edge_len_ratio, fix)
is_cached_loaded = False
if os.path.exists(rm_file_path):
gs.logger.debug("Remeshed file (`.rm`) found in cache.")
try:
with open(rm_file_path, "rb") as file:
verts, faces = pkl.load(file)
is_cached_loaded = True
except (EOFError, ModuleNotFoundError, pkl.UnpicklingError):
gs.logger.info("Ignoring corrupted cache.")
if not is_cached_loaded:
# Importing pymeshlab is very slow and not used very often. Let's delay import.
with open(os.devnull, "w") as stderr, redirect_libc_stderr(stderr):
import pymeshlab
gs.logger.info("Remeshing for tetrahedralization...")
ms = pymeshlab.MeshSet()
ms.add_mesh(pymeshlab.Mesh(vertex_matrix=self.verts, face_matrix=self.faces))
if edge_len_abs is not None:
ms.meshing_isotropic_explicit_remeshing(targetlen=pymeshlab.PureValue(edge_len_abs))
else:
ms.meshing_isotropic_explicit_remeshing(targetlen=pymeshlab.PercentageValue(edge_len_ratio * 100))
m = ms.current_mesh()
verts, faces = m.vertex_matrix(), m.face_matrix()
# Maybe we need to fix the mesh in some extreme cases with open3d
# if fix:
# verts, faces = pymeshfix.clean_from_arrays(verts, faces)
os.makedirs(os.path.dirname(rm_file_path), exist_ok=True)
with open(rm_file_path, "wb") as file:
pkl.dump((verts, faces), file)
self._mesh = trimesh.Trimesh(
vertices=verts,
faces=faces,
)
self.clear_visuals()
[docs]
def tetrahedralize(self, tet_cfg):
"""
Tetrahedralize the mesh.
"""
return mu.tetrahedralize_mesh(self._mesh, tet_cfg)
[docs]
def particlize(
self,
p_size=0.01,
sampler="random",
):
"""
Sample particles using the mesh volume.
"""
if "pbs" in sampler:
return pu.trimesh_to_particles_pbs(self._mesh, p_size, sampler)
return pu.trimesh_to_particles_simple(self._mesh, p_size, sampler)
[docs]
def clear_visuals(self):
"""
Clear the mesh's visual attributes by resetting the surface to gs.surfaces.Default().
"""
self._surface = gs.surfaces.Default()
self._surface.update_texture()
[docs]
def get_unique_edges(self):
"""
Get the unique edges of the mesh.
"""
r_face = np.roll(self.faces, 1, axis=1)
edges = np.concatenate(np.array([self.faces, r_face]).T)
# do a first pass to remove duplicates
edges.sort(axis=1)
edges = np.unique(edges, axis=0)
edges = edges[edges[:, 0] != edges[:, 1]]
return edges
[docs]
def copy(self):
"""
Copy the mesh.
"""
return Mesh(
mesh=self._mesh.copy(include_cache=True),
surface=self._surface.copy(),
uvs=self._uvs.copy() if self._uvs is not None else None,
metadata=self._metadata.copy(),
)
[docs]
@classmethod
def from_trimesh(
cls,
mesh,
scale=None,
convexify=False,
decimate=False,
decimate_face_num=500,
decimate_aggressiveness=2,
metadata=None,
surface=None,
):
"""
Create a genesis.Mesh from a trimesh.Trimesh object.
"""
if surface is None:
surface = gs.surfaces.Default()
surface.update_texture()
else:
surface = surface.copy()
mesh = mesh.copy(include_cache=True)
try: # always parse uvs because roughness and normal map also need uvs
uvs = mesh.visual.uv.copy()
uvs[:, 1] = 1.0 - uvs[:, 1] # trimesh uses uvs starting from top left corner
except:
uvs = None
roughness_factor = None
color_image = None
color_factor = None
opacity = 1.0
if mesh.visual.defined:
if mesh.visual.kind == "texture":
material = mesh.visual.material
# TODO: Parsing PBR in obj or not
# trimesh from .obj file will never use PBR material, but that from .glb file will
if isinstance(material, trimesh.visual.material.PBRMaterial):
# color_image = None
# color_factor = None
if material.baseColorTexture is not None:
color_image = mu.PIL_to_array(material.baseColorTexture)
if material.baseColorFactor is not None:
color_factor = tuple(np.array(material.baseColorFactor, dtype=np.float32) / 255.0)
if material.roughnessFactor is not None:
roughness_factor = (material.roughnessFactor,)
elif isinstance(material, trimesh.visual.material.SimpleMaterial):
if material.image is not None:
color_image = mu.PIL_to_array(material.image)
elif material.diffuse is not None:
color_factor = tuple(np.array(material.diffuse, dtype=np.float32) / 255.0)
if material.glossiness is not None:
roughness_factor = ((2 / (material.glossiness + 2)) ** (1.0 / 4.0),)
opacity = float(material.kwargs.get("d", [1.0])[0])
if opacity < 1.0:
if color_factor is None:
color_factor = (1.0, 1.0, 1.0, opacity)
else:
color_factor = (*color_factor[:3], color_factor[3] * opacity)
else:
gs.raise_exception()
else:
# TODO: support vertex/face colors in luisa
color_factor = tuple(np.array(mesh.visual.main_color, dtype=np.float32) / 255.0)
else:
# use white color as default
color_factor = (1.0, 1.0, 1.0, 1.0)
color_texture = mu.create_texture(color_image, color_factor, "srgb")
opacity_texture = None
if color_texture is not None:
opacity_texture = color_texture.check_dim(3)
roughness_texture = mu.create_texture(None, roughness_factor, "linear")
surface.update_texture(
color_texture=color_texture,
opacity_texture=opacity_texture,
roughness_texture=roughness_texture,
)
mesh.visual = mu.surface_uvs_to_trimesh_visual(surface, uvs, len(mesh.vertices))
if scale is not None:
mesh.vertices *= scale
return cls(
mesh=mesh,
surface=surface,
uvs=uvs,
convexify=convexify,
decimate=decimate,
decimate_face_num=decimate_face_num,
decimate_aggressiveness=decimate_aggressiveness,
metadata=metadata,
)
[docs]
@classmethod
def from_attrs(cls, verts, faces, normals=None, surface=None, uvs=None, scale=None):
"""
Create a genesis.Mesh from mesh attribtues including vertices, faces, and normals.
"""
if surface is None:
surface = gs.surfaces.Default()
return cls(
mesh=trimesh.Trimesh(
vertices=verts * scale if scale is not None else verts,
faces=faces,
vertex_normals=normals,
visual=mu.surface_uvs_to_trimesh_visual(surface, uvs, len(verts)),
process=False,
),
surface=surface,
uvs=uvs,
)
[docs]
@classmethod
def from_morph_surface(cls, morph, surface=None):
"""
Create a genesis.Mesh from morph and surface options.
If the morph is a Mesh morph (morphs.Mesh), it could contain multiple submeshes, so we return a list.
"""
if isinstance(morph, gs.options.morphs.Mesh):
if morph.is_format(gs.options.morphs.MESH_FORMATS):
meshes = mu.parse_mesh_trimesh(morph.file, morph.group_by_material, morph.scale, surface)
elif morph.is_format(gs.options.morphs.GLTF_FORMATS):
if not morph.parse_glb_with_zup:
gs.logger.warning(
"GLTF is using y-up while Genesis uses z-up. Please set parse_glb_with_zup=True"
" in morph options if you find the mesh is 90-degree rotated. We will set parse_glb_with_zup=True"
" and rotate glb mesh by default later and gradually enforce this option."
)
if morph.parse_glb_with_trimesh:
meshes = mu.parse_mesh_trimesh(morph.file, morph.group_by_material, morph.scale, surface)
if morph.parse_glb_with_zup:
for mesh in meshes:
mesh.apply_transform(mu.Y_UP_TRANSFORM.T)
else:
meshes = gltf_utils.parse_mesh_glb(
morph.file, morph.group_by_material, morph.scale, surface, morph.parse_glb_with_zup
)
elif morph.is_format(gs.options.morphs.USD_FORMATS):
import genesis.utils.usda as usda_utils
meshes = usda_utils.parse_mesh_usd(morph.file, morph.group_by_material, morph.scale, surface)
elif isinstance(morph, gs.options.morphs.MeshSet):
assert all(isinstance(mesh, trimesh.Trimesh) for mesh in morph.files)
meshes = [mu.trimesh_to_mesh(mesh, morph.scale, surface) for mesh in morph.files]
else:
gs.raise_exception(
f"File type not supported (yet). Submit a feature request if you need this: {morph.file}."
)
return meshes
else:
if isinstance(morph, gs.options.morphs.Box):
tmesh = mu.create_box(extents=morph.size)
elif isinstance(morph, gs.options.morphs.Cylinder):
tmesh = mu.create_cylinder(radius=morph.radius, height=morph.height)
elif isinstance(morph, gs.options.morphs.Sphere):
tmesh = mu.create_sphere(radius=morph.radius)
else:
gs.raise_exception()
metadata = {"mesh_path": morph.file} if isinstance(morph, gs.options.morphs.FileMorph) else {}
return cls.from_trimesh(tmesh, surface=surface, metadata=metadata)
[docs]
def set_color(self, color):
"""
Set the mesh's color.
"""
self._color = color
color_texture = gs.textures.ColorTexture(color=tuple(color))
opacity_texture = color_texture.check_dim(3)
self._surface.update_texture(color_texture=color_texture, opacity_texture=opacity_texture, force=True)
self.update_trimesh_visual()
[docs]
def update_trimesh_visual(self):
"""
Update the trimesh obj's visual attributes using its surface and uvs.
"""
self._mesh.visual = mu.surface_uvs_to_trimesh_visual(self.surface, self.uvs, len(self.verts))
[docs]
def show(self):
"""
Visualize the mesh using trimesh's built-in viewer.
"""
return self._mesh.show()
@property
def uid(self):
"""
Return the mesh's uid.
"""
return self._uid
@property
def trimesh(self):
"""
Return the mesh's trimesh object.
"""
return self._mesh
@property
def is_convex(self) -> bool:
"""
Whether the mesh is convex.
"""
return self._mesh.is_convex
@property
def metadata(self):
"""
Metadata of the mesh.
"""
return self._metadata
@property
def verts(self):
"""
Vertices of the mesh.
"""
return self._mesh.vertices
@verts.setter
def verts(self, verts):
"""
Set the vertices of the mesh.
"""
assert len(verts) == len(self.verts)
self._mesh.vertices = verts
@property
def faces(self):
"""
Faces of the mesh.
"""
return self._mesh.faces
@property
def normals(self):
"""
Normals of the mesh.
"""
return self._mesh.vertex_normals
@property
def surface(self):
"""
Surface of the mesh.
"""
return self._surface
@property
def uvs(self):
"""
UVs of the mesh.
"""
return self._uvs
@property
def area(self):
"""
Surface area of the mesh.
"""
return self._mesh.area
@property
def volume(self):
"""
Volume of the mesh.
"""
return self._mesh.volume