import os
import pickle as pkl
from typing import Any
import fast_simplification
import numpy as np
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`.
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: "np.typing.NDArray | None" = None,
scale: "np.typing.NDArray | float | None" = None,
convexify=False,
decimate=False,
decimate_face_num=500,
decimate_aggressiveness=0,
metadata=None,
is_mesh_zup: bool = True,
):
self._uid = gs.UID()
self._mesh = mesh # .copy() FIXME: For some reason forcing copy is causing some tests to fails...
self._surface = surface
if uvs is not None:
uvs = uvs.astype(gs.np_float, copy=False)
self._uvs = uvs
self._metadata: dict[str, Any] = metadata or {}
self._color = np.array([1.0, 1.0, 1.0, 1.0], dtype=gs.np_float)
# By default, all meshes are considered zup, unless the "FileMorph.file_meshes_are_zup" option was set to False
self._metadata.setdefault("imported_as_zup", True)
# By default, all meshes are considered having their original visual
self._metadata.setdefault("is_visual_overwritten", False)
if not is_mesh_zup:
if self._metadata["imported_as_zup"]:
self._mesh.apply_transform(mu.Y_UP_TRANSFORM.T)
self._metadata["imported_as_zup"] = False
if scale is not None:
scale = np.atleast_1d(np.asarray(scale))
assert scale.ndim == 1 and scale.size in (1, 3)
self._mesh.apply_scale(scale)
if self._surface.requires_uv(): # check uvs here
if self._uvs is None:
if "mesh_path" in self._metadata:
gs.logger.warning(
f"Texture given but asset missing uv info (or failed to load): {self._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, TypeError, MemoryError):
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(**(dict(include_cache=True) if isinstance(self._mesh, trimesh.Trimesh) else {})),
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,
is_mesh_zup=True,
):
"""
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(**(dict(include_cache=True) if isinstance(mesh, trimesh.Trimesh) else {}))
# Always parse uvs if available because roughness and normal map also need uvs.
# Note that some visual may not have uv, e.g. ColorVisuals.
uvs = None
if isinstance(mesh.visual, trimesh.visual.texture.TextureVisuals) and mesh.visual.uv is not None:
# Note that 'trimesh' uses uvs starting from top left corner.
uvs = mesh.visual.uv.copy()
uvs[:, 1] = 1.0 - uvs[:, 1]
metadata = metadata or {}
must_update_surface = True
roughness_factor = None
color_image = None
color_factor = None
opacity = 1.0
visual = mesh.visual
if isinstance(visual, trimesh.visual.texture.TextureVisuals) and visual.defined:
if visual.kind == "texture":
material = 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):
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 = (mu.glossiness_to_roughness(material.glossiness),)
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(f"Unsupported Trimesh material type '{type(material)}'.")
else:
# TODO: support vertex/face colors in luisa
color_factor = tuple(np.array(visual.main_color, dtype=np.float32) / 255.0)
elif surface.color is not None:
color_factor = surface.color
metadata["is_visual_overwritten"] = True
elif (isinstance(visual, trimesh.visual.color.ColorVisuals) and visual.defined) or (
isinstance(visual, trimesh.visual.color.VertexColor) and visual.vertex_colors.size > 0
):
# Color is already vertex-based. It is not only necessary to create a new visual.
must_update_surface = False
else:
# use white color as default
color_factor = (1.0, 1.0, 1.0, 1.0)
if must_update_surface:
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))
return cls(
mesh=mesh,
surface=surface,
uvs=uvs,
scale=scale,
convexify=convexify,
decimate=decimate,
decimate_face_num=decimate_face_num,
decimate_aggressiveness=decimate_aggressiveness,
metadata=metadata,
is_mesh_zup=is_mesh_zup,
)
[docs] @classmethod
def from_attrs(
cls, verts, faces, normals=None, surface=None, uvs=None, scale=None, metadata=None, is_mesh_zup=True
):
"""
Create a genesis.Mesh from mesh attributes including vertices, faces, and normals.
"""
if surface is None:
surface = gs.surfaces.Default()
metadata = metadata or {}
metadata["is_visual_overwritten"] = metadata.get("is_visual_overwritten", False) or (surface.color is not None)
visual = mu.surface_uvs_to_trimesh_visual(surface, uvs, len(verts))
tmesh = trimesh.Trimesh(
vertices=verts,
faces=faces,
vertex_normals=normals,
visual=visual,
process=False,
)
return cls(
mesh=tmesh,
surface=surface,
uvs=uvs,
scale=scale,
metadata=metadata,
is_mesh_zup=is_mesh_zup,
)
[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 sub-meshes, so we return a list.
"""
if isinstance(morph, gs.options.morphs.Mesh):
if morph.is_format(gs.options.morphs.MESH_FORMATS):
if morph.is_format(gs.options.morphs.GLTF_FORMATS):
meshes = gltf_utils.parse_mesh_glb(
morph.file, morph.group_by_material, morph.scale, morph.file_meshes_are_zup, surface
)
else:
meshes = mu.parse_mesh_trimesh(
morph.file, morph.group_by_material, morph.scale, morph.file_meshes_are_zup, 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: {morph.file}")
return meshes
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()
return cls.from_trimesh(tmesh, surface=surface)
[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))
self._metadata["is_visual_overwritten"] = True
@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