Source code for genesis.engine.entities.hybrid_entity

import xml.etree.ElementTree as ET
from pathlib import Path

import numpy as np
import gstaichi as ti
import trimesh

import genesis as gs
import genesis.utils.array_class as array_class
import genesis.utils.geom as gu
import genesis.utils.urdf as uu
from genesis.ext.urdfpy.urdf import URDF

from genesis.utils.hybrid import (
    check_graph,
    compute_graph_attribute,
    gel_graph_to_nx_graph,
    graph_to_tree,
    reduce_graph,
    skeletonization,
    trimesh_to_gelmesh,
)
from genesis.utils.mesh import load_mesh

from .base_entity import Entity
from .mpm_entity import MPMEntity


[docs]@ti.data_oriented class HybridEntity(Entity): """ A hybrid simulation entity composed of both rigid and soft components. This class encapsulates logic for initializing, coupling, and simulating a physically hybrid object that has rigid bodies (e.g., from URDF or meshes) and soft materials (e.g., MPM). The coupling allows for bi-directional force and motion interaction between the rigid and soft parts during simulation. Parameters ---------- idx : int The index of the entity within the scene. scene : genesis.Scene The simulation scene where the entity is added. material : genesis.materials.Hybrid The hybrid material that includes both rigid and soft sub-materials, and defines coupling behavior. morph : genesis.morphs.Morph The shape/morphology of the entity. Must be either a `URDF` or `Mesh`. surface : genesis.surfaces.Surface The surface properties applied to the soft part of the entity. """ def __init__( self, idx, scene, material, morph, surface, name: str | None = None, ): super().__init__(idx, scene, morph, None, material, surface, name=name) material_rigid = material.material_rigid material_soft = material.material_soft surface_rigid = gs.surfaces.Smooth(roughness=0.4) # HACK hardcoded assert isinstance(material_soft, gs.materials.MPM.Base) # TODO: need FEM and PBD if isinstance(morph, gs.morphs.URDF): # set up rigid part if material.use_default_coupling: gs.logger.info("Use default coupling in hybrid. Overwrite `needs_coup` in rigid material to True") material_rigid._needs_coup = True else: gs.logger.info("Use default coupling in hybrid. Overwrite `needs_coup` in rigid material to False") material_rigid._needs_coup = False part_rigid = scene.add_entity( material=material_rigid, morph=morph, surface=surface_rigid, ) # get rigid part in world coords augment_link_world_coords(part_rigid) # set soft parts based on rigid links func_instantiate_soft_from_rigid = ( material._func_instantiate_soft_from_rigid or default_func_instantiate_soft_from_rigid ) part_soft = func_instantiate_soft_from_rigid( scene=scene, part_rigid=part_rigid, material_soft=material_soft, material_hybrid=material, surface=surface, ) elif isinstance(morph, gs.morphs.Mesh): # instantiate soft part part_soft = scene.add_entity( material=material_soft, morph=morph, surface=surface, ) # load mesh in the same way as the soft entity mesh = load_mesh(morph.file) # instantiate rigid part if material._func_instantiate_rigid_from_soft is None: func_instantiate_rigid_from_soft = default_func_instantiate_rigid_from_soft else: func_instantiate_rigid_from_soft = material._func_instantiate_rigid_from_soft part_rigid = func_instantiate_rigid_from_soft( scene=scene, mesh=mesh, morph=morph, material_rigid=material_rigid, material_hybrid=material, surface=surface_rigid, ) else: raise ValueError("`morph` in hybrid entity should be either URDF or Mesh") if not material.use_default_coupling: # get rigid-soft association function if material._func_instantiate_rigid_soft_association is None: if isinstance(morph, gs.morphs.URDF): func_instantiate_rigid_soft_association = default_func_instantiate_rigid_soft_association_from_rigid elif isinstance(morph, gs.morphs.Mesh): func_instantiate_rigid_soft_association = default_func_instantiate_rigid_soft_association_from_soft else: func_instantiate_rigid_soft_association = material._func_instantiate_rigid_soft_association muscle_group, link_idcs, geom_idcs, trans_local_to_global, quat_local_to_global = ( func_instantiate_rigid_soft_association( part_rigid=part_rigid, part_soft=part_soft, ) ) if muscle_group is not None: muscle_group = muscle_group.astype(gs.np_int, copy=False) # set muscle group material_soft._n_groups = len(link_idcs) self._muscle_group_cache = muscle_group # set up info in taichi field if isinstance(material_soft, gs.materials.MPM.Base): part_soft_info = ti.types.struct( link_idx=gs.ti_int, geom_idx=gs.ti_int, trans_local_to_global=gs.ti_vec3, quat_local_to_global=gs.ti_vec4, ).field(shape=(material_soft.n_groups,), needs_grad=False, layout=ti.Layout.SOA) part_soft_info.link_idx.from_numpy(np.asarray(link_idcs, dtype=gs.np_int)) part_soft_info.geom_idx.from_numpy(np.asarray(geom_idcs, dtype=gs.np_int)) part_soft_info.trans_local_to_global.from_numpy(np.asarray(trans_local_to_global, dtype=gs.np_float)) part_soft_info.quat_local_to_global.from_numpy(np.asarray(quat_local_to_global, dtype=gs.np_float)) part_soft_init_positions = ti.field(dtype=gs.ti_vec3, shape=(part_soft.init_particles.shape[0],)) part_soft_init_positions.from_torch(gs.Tensor(part_soft.init_particles)) self._part_soft_info = part_soft_info self._part_soft_init_positions = part_soft_init_positions else: raise ValueError(f"Cannot handle soft material {material_soft}") # set coupling func def wrap_func(func, before=False): def wrapper(f): if before: self.update_soft_part(f) func(f) if not before: self.update_soft_part(f) return wrapper if isinstance(material_soft, gs.materials.MPM.Base): # NOTE: coupling operating at particle level and here we modify post_coupling, i.e., update particle state after g2p self._update_soft_part_at_pre_coupling = False if self._update_soft_part_at_pre_coupling: part_soft.solver.substep_pre_coupling = wrap_func( part_soft.solver.substep_pre_coupling, before=True ) else: part_soft.solver.substep_post_coupling = wrap_func( part_soft.solver.substep_post_coupling, before=False ) else: raise ValueError(f"Cannot handle soft material {material_soft}") # set members self._material_rigid = material_rigid self._part_rigid = part_rigid self._solver_rigid = part_rigid.solver self._material_soft = material_soft self._part_soft = part_soft self._solver_soft = part_soft.solver # TODO: test with different dt assert self._solver_rigid.dt == self._solver_soft.dt, "Rigid and soft solver should have the same dt for now" # ------------------------------------------------------------------------------------ # --------------------------------- naming methods ----------------------------------- # ------------------------------------------------------------------------------------ def _get_morph_identifier(self) -> str: morph = self._morph if isinstance(morph, gs.morphs.URDF): if isinstance(morph.file, str): # Try to get robot name from URDF file, fall back to filename stem try: return uu.get_robot_name(morph.file) except (ValueError, ET.ParseError, FileNotFoundError, OSError) as e: gs.logger.warning(f"Could not extract robot name from URDF: {e}. Using filename stem instead.") return Path(morph.file).stem return morph.file.name if isinstance(morph, gs.morphs.Mesh): return Path(morph.file).stem return "hybrid" # ------------------------------------------------------------------------------------ # ----------------------------------- basic ops -------------------------------------- # ------------------------------------------------------------------------------------
[docs] def get_dofs_position(self, *args, **kwargs): """ Get the current generalized coordinates (positions) of the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's get_dofs_position method. Returns ------- gs.Tensor A tensor containing the position values of the rigid body's degrees of freedom. """ return self._part_rigid.get_dofs_position(*args, **kwargs)
[docs] def get_dofs_velocity(self, *args, **kwargs): """ Get the current generalized velocities of the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's get_dofs_velocity method. Returns ------- gs.Tensor A tensor containing the velocity values of the rigid body's degrees of freedom. """ return self._part_rigid.get_dofs_velocity(*args, **kwargs)
[docs] def get_dofs_force(self, *args, **kwargs): """ Get the current generalized forces applied on the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's get_dofs_force method. Returns ------- gs.Tensor A tensor representing forces acting on the rigid body's degrees of freedom. """ return self._part_rigid.get_dofs_force(*args, **kwargs)
[docs] def get_dofs_control_force(self, *args, **kwargs): """ Get the control forces currently applied to the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's get_dofs_control_force method. Returns ------- gs.Tensor A tensor containing the control force values for the rigid body's degrees of freedom. """ return self._part_rigid.get_dofs_control_force(*args, **kwargs)
[docs] def set_dofs_velocity(self, *args, **kwargs): """ Set the generalized velocities for the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's set_dofs_velocity method. """ self._part_rigid.set_dofs_velocity(*args, **kwargs)
[docs] def set_dofs_force(self, *args, **kwargs): """ Set the generalized forces for the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's set_dofs_force method. """ self._part_rigid.set_dofs_force(*args, **kwargs)
[docs] def control_dofs_position(self, *args, **kwargs): """ Apply position control to the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's control_dofs_position method. Returns ------- gs.Tensor Control output for position adjustment of the rigid body's DOFs. """ return self._part_rigid.control_dofs_position(*args, **kwargs)
[docs] def control_dofs_position_velocity(self, *args, **kwargs): """ Apply position control to the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's control_dofs_position method. Returns ------- gs.Tensor Control output for position adjustment of the rigid body's DOFs. """ return self._part_rigid.control_dofs_position_velocity(*args, **kwargs)
[docs] def control_dofs_velocity(self, *args, **kwargs): """ Apply velocity control to the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's control_dofs_velocity method. Returns ------- gs.Tensor Control output for velocity adjustment of the rigid body's DOFs. """ self._part_rigid.control_dofs_velocity(*args, **kwargs)
[docs] def control_dofs_force(self, *args, **kwargs): """ Apply force control to the rigid part of the hybrid entity. Parameters ---------- *args, **kwargs Passed directly to the rigid entity's control_dofs_force method. Returns ------- gs.Tensor Control output for force adjustment of the rigid body's DOFs. """ self._part_rigid.control_dofs_force(*args, **kwargs)
# ------------------------------------------------------------------------------------ # ----------------------------------- instantiation ---------------------------------- # ------------------------------------------------------------------------------------
[docs] def build(self): """ Finalize the hybrid entity setup during simulation build. """ # can only be called here (at sim build) if not self.material.use_default_coupling and self._muscle_group_cache is not None: self._part_soft.set_muscle( muscle_group=gs.tensor(self._muscle_group_cache), # no muscle direction as the soft body is actuated by rigid parts )
[docs] def update_soft_part(self, f): """ Update the state of the soft part of the hybrid entity based on the current simulation frame. Parameters ---------- f : int The current simulation frame index. """ if isinstance(self._part_soft, MPMEntity): self._kernel_update_soft_part_mpm( f=f, geoms_info=self._solver_rigid.geoms_info, links_state=self._solver_rigid.links_state ) else: raise NotImplementedError
@ti.kernel def _kernel_update_soft_part_mpm( self, f: ti.i32, geoms_info: array_class.GeomsInfo, links_state: array_class.LinksState, ): for i_p_, i_b in ti.ndrange(self._part_soft.n_particles, self._part_soft._sim._B): if self._solver_soft.particles_ng[f, i_p_, i_b].active: i_global = i_p_ + self._part_soft.particle_start f_ = f if ti.static(not self._update_soft_part_at_pre_coupling): f_ = f + 1 # NOTE: this is after g2p and thus we use f + 1 # get corresponding link group_idx = self._solver_soft.particles_info[i_global].muscle_group link_idx = self._part_soft_info.link_idx[group_idx] geom_idx = self._part_soft_info.geom_idx[group_idx] trans_local_to_global = self._part_soft_info.trans_local_to_global[group_idx] quat_local_to_global = self._part_soft_info.quat_local_to_global[group_idx] g_pos_0 = geoms_info.pos[geom_idx] g_quat_0 = geoms_info.quat[geom_idx] # compute new pos in minimal coordinate using rigid-bodied dynamics x_init_pos = self._part_soft_init_positions[i_p_] x_init_local = gu.ti_inv_transform_by_trans_quat( x_init_pos, trans_local_to_global, quat_local_to_global ) scaled_pos = gu.ti_transform_by_quat( gu.ti_inv_transform_by_quat(g_pos_0, g_quat_0), g_quat_0, ) tx_pos, tx_quat = gu.ti_transform_pos_quat_by_trans_quat( scaled_pos, g_quat_0, links_state.pos[link_idx, i_b], links_state.quat[link_idx, i_b], ) new_x_pos = gu.ti_transform_by_trans_quat( x_init_local, trans=tx_pos, quat=tx_quat, ) # compute velocity in closed-loop setting dt = self._solver_soft.substep_dt dt_scale = ( self._solver_soft.substep_dt / self._solver_rigid.dt ) # NOTE: move soft part incrementally at soft solver's substeps x_pos = self._solver_soft.particles.pos[f_, i_global, i_b] xd_vel = (new_x_pos - x_pos) / dt xd_vel *= dt_scale # assume linear scaling between the timestep difference of soft/rigid solver vel_d = xd_vel - self._solver_soft.particles.vel[f_, i_global, i_b] vel_d *= ti.exp(-self._solver_soft.dt * self.material.damping) # soft-to-rigid coupling dt_for_rigid_acc = ( self._solver_rigid.dt ) # NOTE: use rigid dt here as we are sorta doing integration within soft solver substep mass_real = self._solver_soft.particles_info[i_global].mass / self._solver_soft._particle_volume_scale acc = vel_d / dt_for_rigid_acc frc_vel = mass_real * acc frc_ang = (x_pos - links_state.root_COM[link_idx, i_b]).cross(frc_vel) links_state.cfrc_coupling_vel[link_idx, i_b] += frc_vel links_state.cfrc_coupling_ang[link_idx, i_b] += frc_ang # rigid-to-soft coupling # NOTE: this may lead to unstable feedback loop self._solver_soft.particles.vel[f_, i_global, i_b] += vel_d * self.material.soft_dv_coef # ------------------------------------------------------------------------------------ # ----------------------------------- properties ------------------------------------- # ------------------------------------------------------------------------------------ @property def n_dofs(self) -> int: """The number of degrees of freedom of the hybrid entity (inherited from the rigid part).""" return self._part_rigid.n_dofs @property def fixed(self) -> bool: """Check whether the hybrid entity is fixed in space (inherited from the rigid morph).""" return self._part_rigid.morph.fixed @property def part_rigid(self): """The rigid part of the hybrid entity.""" return self._part_rigid @property def part_soft(self): """The soft part of the hybrid entity.""" return self._part_soft @property def solver_rigid(self): """The solver associated with the rigid part of the hybrid entity.""" return self._solver_rigid @property def solver_soft(self): """The solver associated with the soft part of the hybrid entity.""" return self._solver_soft
# ------------------------------------------------------------------------------------ # ------------------------------------- misc ----------------------------------------- # ------------------------------------------------------------------------------------ def augment_link_world_coords(part_rigid): ordered_links_idx = [link.idx for link in part_rigid.links] # now links are pre-ordered for i, i_l_global in enumerate(ordered_links_idx): i_l = i_l_global - part_rigid.link_start # NOTE: seems like by default link idx is global link = part_rigid.links[i_l] i_p = link.parent_idx parent_pos = np.zeros((3,)) parent_quat = gu.identity_quat() if i_p != -1: link_p = part_rigid.links[i_p - part_rigid.link_start] # NOTE: seems like by default link idx is global parent_pos = link_p.init_x_pos parent_quat = link_p.init_x_quat if (link.joints and link.joints[0].type == gs.JOINT_TYPE.FREE) or ( i_p == -1 and (not link.joints or link.joints[0].type == gs.JOINT_TYPE.FIXED) ): link.init_x_pos = link.pos link.init_x_quat = link.quat else: link.init_x_pos, link.init_x_quat = gu.transform_pos_quat_by_trans_quat( link.pos, link.quat, parent_pos, parent_quat ) for joint in link.joints: link.init_x_pos, link.init_x_quat = gu.transform_pos_quat_by_trans_quat( joint.pos, joint.quat, link.init_x_pos, link.init_x_quat ) def _visualize_muscle_group(positions, muscle_group): import open3d as o3d pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(positions) colors = np.zeros((positions.shape[0], 3)) for gii, group_id in enumerate(np.unique(muscle_group)): colors[muscle_group == group_id] = np.random.uniform(0, 1, (3,)) pcd.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw([pcd]) # ------------------------------------------------------------------------------------ # ----------------------------- default instantiation -------------------------------- # ------------------------------------------------------------------------------------ def default_func_instantiate_soft_from_rigid( scene, part_rigid, material_soft, material_hybrid, surface, ): meshes = [] trans_local_to_global = [] euler_local_to_global = [] for link in part_rigid.links: if len(link.geoms) < 1: # no collision geom continue geom = link.geoms[0] # NOTE: collision geom is always the prior one based on the URDF parser trans, quat = gu.transform_pos_quat_by_trans_quat( geom.init_pos, geom.init_quat, link.init_x_pos, link.init_x_quat ) euler = gu.quat_to_xyz(quat, rpy=True, degrees=True) # can also do link.init_verts here and it seems to have more indices than geom.init_verts (but there is no idx_offset_vert) verts = geom.init_verts assert hasattr(geom, "init_normals") inner_mesh = trimesh.Trimesh( vertices=verts, # NOTE: scale is already applied here faces=geom.init_faces, vertex_normals=geom.init_normals, ) outer_verts = verts + inner_mesh.vertex_normals * material_hybrid.thickness outer_mesh = trimesh.Trimesh( vertices=outer_verts, faces=geom.init_faces, ) # mesh = trimesh.boolean.difference([outer_mesh, inner_mesh]) # wrap around the rigid link mesh = outer_mesh # FIXME: hack to avoid `ValueError: No backends available for boolean operations!` meshes.append(mesh) trans_local_to_global.append(trans) euler_local_to_global.append(euler) rm_cross_link_overlap_mesh = False if rm_cross_link_overlap_mesh: for i, mesh in enumerate(meshes[:-1]): # remove cross-link overlapping area meshes[i] = trimesh.boolean.difference([mesh] + meshes[i + 1 :]) part_soft = scene.add_entity( material=material_soft, morph=gs.morphs.MeshSet( files=meshes, poss=trans_local_to_global, eulers=euler_local_to_global, scale=1, # scale is already handled in geom.init_verts ), surface=surface, ) return part_soft def default_func_instantiate_rigid_from_soft( scene, mesh, morph, material_rigid, material_hybrid, surface, ): # skeletonization gelmesh = trimesh_to_gelmesh(mesh) graph_gel = skeletonization(gelmesh) # convert to nxgraph graph_nx = gel_graph_to_nx_graph(graph_gel) check_graph(graph_nx) # compute graph attribute graph_pos = graph_gel.positions() compute_graph_attribute(graph_nx, graph_pos) # reduce nxgraph graph_nx_reduced = reduce_graph(graph_nx, straight_thresh=60) check_graph(graph_nx_reduced) # to URDF G = graph_nx_reduced G, src_node = graph_to_tree(G) urdf = URDF.from_nxgraph(G) # add rigid entity mesh_center = (mesh.vertices.max(0) + mesh.vertices.min(0)) / 2.0 offset = (graph_pos[src_node] - mesh_center) * morph.scale pos_rigid = morph.pos + offset.astype(gs.np_float, copy=False) quat_rigid = morph.quat scale_rigid = morph.scale morph_rigid = gs.morphs.URDF( file=urdf, pos=pos_rigid, quat=quat_rigid, scale=scale_rigid, fixed=morph.fixed, ) part_rigid = scene.add_entity( material=material_rigid, morph=morph_rigid, surface=surface, ) return part_rigid def default_func_instantiate_rigid_soft_association_from_rigid( part_rigid, part_soft, ): muscle_group = None # instantiate soft from rigid already set muscle group using MeshSet link_idcs = [] geom_idcs = [] trans_local_to_global = [] quat_local_to_global = [] for link in part_rigid.links: if len(link.geoms) < 1: # no collision geom continue geom = link.geoms[0] # NOTE: collision geom is always the prior one based on the URDF parser trans, quat = gu.transform_pos_quat_by_trans_quat( geom.init_pos, geom.init_quat, link.init_x_pos, link.init_x_quat ) link_idcs.append(link.idx) geom_idcs.append(geom.idx) trans_local_to_global.append(trans) quat_local_to_global.append(quat) return muscle_group, link_idcs, geom_idcs, trans_local_to_global, quat_local_to_global def default_func_instantiate_rigid_soft_association_from_soft( part_rigid, part_soft, ): # compute distance between particle position and line segment of each link augment_link_world_coords(part_rigid) positions = part_soft.init_particles dist_to_links = [] link_idcs = [] geom_idcs = [] trans_local_to_global = [] quat_local_to_global = [] for i, link in enumerate(part_rigid.links): geom = link.geoms[0] link_end_x_pos = ( gu.transform_by_trans_quat( np.zeros((3,)), trans=geom.init_pos * 2, quat=geom.init_quat, ) + link.init_x_pos ) # TODO: check if this is correct p0 = link.init_x_pos p1 = link_end_x_pos line_vec = p1 - p0 # NOTE: assume a link is a line segment line_length = np.linalg.norm(line_vec) positions_proj_on_line_t = (positions - p0) @ line_vec / (line_length**2) dist_to_p0 = np.linalg.norm(positions - p0, axis=-1) dist_to_p1 = np.linalg.norm(positions - p1, axis=-1) dist_to_line = np.sqrt(dist_to_p0**2 - ((positions_proj_on_line_t[:, None] * line_vec) ** 2).sum(-1)) is_clipped_low = positions_proj_on_line_t < 0.0 is_clipped_high = positions_proj_on_line_t > 1.0 is_valid = ~(is_clipped_low | is_clipped_high) dist_to_link = dist_to_p0 * is_clipped_low + dist_to_p1 * is_clipped_high + dist_to_line * is_valid trans, quat = gu.transform_pos_quat_by_trans_quat( geom.init_pos, geom.init_quat, link.init_x_pos, link.init_x_quat ) dist_to_links.append(dist_to_link) link_idcs.append(link.idx) geom_idcs.append(geom.idx) trans_local_to_global.append(trans) quat_local_to_global.append(quat) # get muscle group dist_to_links = np.array(dist_to_links) muscle_group = dist_to_links.argmin(axis=0) return muscle_group, link_idcs, geom_idcs, trans_local_to_global, quat_local_to_global