Repository: Genesis-Embodied-AI/Genesis
Pull request #1088: [FEATURE][BREAKING] Add maxvolume support for TetGen-based tetrahedralization, unify TetGen config handling, and add tests
Author: login: kosuke1701
Created: 2025-04-29T03:15:58Z | Merged: 2025-04-29T14:02:21Z

## Description
This PR introduces a `maxvolume` option that lets users cap the maximum tetrahedron volume generated by TetGen.
While adding this feature, the TetGen pipeline was refactored as follows:

* **Using switch string** – A new `utils.mesh.make_tetgen_switches()` helper was introduced to convert a config dict into the canonical TetGen switch string. This addresses an issue where the existing TetGen wrapper could silently ignore options like `maxvolume`.

* **Entity simplification** – Previously, FEM and PBD entities had separate implementations for generating TetGen configurations from a morph instance. These were unified to use a single helper (`utils.mesh.generate_tetgen_config_from_morph`), making behaviour consistent across solvers.

* **Public API change (BREAKING)** – `Mesh.tetrahedralize()` used in `PBD3DEntity` was updated to match FEM by taking a single `tet_cfg` dict instead of a long list of positional kwargs. All in-tree callers were updated accordingly.

* **Tests** –  
  * `tests/test_fem.py::test_maxvolume`  
  * `tests/test_pbd.py::test_maxvolume`  
  verify that tightening `maxvolume` indeed produces a denser mesh.
  * A temporary cube OBJ fixture (`box_obj_path`) was added so that it can be reusable across other tests that requires an arbitrary `Mesh` instance.


## Related Issue
Resolves https://github.com/Genesis-Embodied-AI/Genesis/issues/1087

## Motivation and Context
* Users could previously control mesh quality but not density; large objects were often under-tessellated, hurting simulation accuracy.

## How Has This Been / Can This Be Tested?
* New pytest cases (see above) ensure that the element count increases when small `maxvolume` is set.
* I have manually inspected tetrahedrons introduced with `maxvolume=0.01` and without it (nolimit) as shown in the following screenshot.

## Screenshots (if appropriate):

![finer_tetra](https://github.com/user-attachments/assets/3252e2e9-8cd9-4354-af0b-c64937911089)

* Edge length of the both cube is 1.
* Left cube is tetrahedralized without `maxvolume` option.
* Right cube is tetrahedralized with `maxvolume=0.01` option.

## Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] I read the **CONTRIBUTING** document.
- [x] I followed the `Submitting Code Changes` section of **CONTRIBUTING** document.
- [x] I tagged the title correctly (including BUG FIX/FEATURE/MISC/BREAKING)
- [x] I updated the [documentation](https://github.com/Genesis-Embodied-AI/genesis-doc) accordingly or no change is needed.
- [x] I tested my changes and added instructions on how to test it for reviewers.

<!--- Optionally -->
- [x] I have added tests to cover my changes.
- [ ] All new and existing tests passed.



Diff:
diff --git a/genesis/engine/entities/fem_entity.py b/genesis/engine/entities/fem_entity.py
index 8fdbbf17e..98d6b9549 100644
--- a/genesis/engine/entities/fem_entity.py
+++ b/genesis/engine/entities/fem_entity.py
@@ -5,6 +5,7 @@
 import genesis as gs
 import genesis.utils.element as eu
 import genesis.utils.geom as gu
+import genesis.utils.mesh as mu
 from genesis.engine.states.cache import QueriedStates
 from genesis.engine.states.entities import FEMEntityState
 from genesis.utils.misc import to_gs_tensor
@@ -496,12 +497,5 @@ def surface_triangles(self):
 
     @property
     def tet_cfg(self):
-        tet_cfg = dict(
-            order=getattr(self.morph, "order", 1),
-            mindihedral=getattr(self.morph, "mindihedral", 10),
-            minratio=getattr(self.morph, "minratio", 1.1),
-            nobisect=getattr(self.morph, "nobisect", True),
-            quality=getattr(self.morph, "quality", True),
-            verbose=getattr(self.morph, "verbose", 0),
-        )
+        tet_cfg = mu.generate_tetgen_config_from_morph(self.morph)
         return tet_cfg
diff --git a/genesis/engine/entities/pbd_entity.py b/genesis/engine/entities/pbd_entity.py
index 1d0b01167..1d38ea0a2 100644
--- a/genesis/engine/entities/pbd_entity.py
+++ b/genesis/engine/entities/pbd_entity.py
@@ -299,14 +299,8 @@ def sample(self):
             gs.raise_exception("Input mesh has zero volume.")
         self._mass = self._vmesh.volume * self.material.rho
 
-        self._particles, self._elems = self._mesh.tetrahedralize(
-            order=getattr(self.morph, "order", 1),
-            mindihedral=getattr(self.morph, "mindihedral", 10),
-            minratio=getattr(self.morph, "minratio", 1.1),
-            nobisect=getattr(self.morph, "nobisect", True),
-            quality=getattr(self.morph, "quality", True),
-            verbose=getattr(self.morph, "verbose", 0),
-        )
+        tet_cfg = mu.generate_tetgen_config_from_morph(self.morph)
+        self._particles, self._elems = self._mesh.tetrahedralize(tet_cfg)
         self._edges = np.array(
             list(
                 set(
diff --git a/genesis/engine/mesh.py b/genesis/engine/mesh.py
index 355b74549..3c2866762 100644
--- a/genesis/engine/mesh.py
+++ b/genesis/engine/mesh.py
@@ -144,7 +144,7 @@ def remesh(self, edge_len_abs=None, edge_len_ratio=0.01, fix=True):
         )
         self.clear_visuals()
 
-    def tetrahedralize(self, order, mindihedral, minratio, nobisect, quality, verbose):
+    def tetrahedralize(self, tet_cfg):
         """
         Tetrahedralize the mesh.
         """
@@ -152,9 +152,8 @@ def tetrahedralize(self, order, mindihedral, minratio, nobisect, quality, verbos
             self.verts, np.concatenate([np.full((self.faces.shape[0], 1), self.faces.shape[1]), self.faces], axis=1)
         )
         tet = tetgen.TetGen(pv_obj)
-        verts, elems = tet.tetrahedralize(
-            order=order, mindihedral=mindihedral, minratio=minratio, nobisect=nobisect, quality=quality, verbose=verbose
-        )
+        switches = mu.make_tetgen_switches(tet_cfg)
+        verts, elems = tet.tetrahedralize(switches=switches)
         # visualize_tet(tet, pv_obj, show_surface=False, plot_cell_qual=False)
         return verts, elems
 
diff --git a/genesis/options/morphs.py b/genesis/options/morphs.py
index 85d4eb852..e92ce4e6c 100644
--- a/genesis/options/morphs.py
+++ b/genesis/options/morphs.py
@@ -16,6 +16,25 @@
 """
 
 
+class TetGenMixin(Options):
+    """
+    A mixin to introduce TetGen-related options into morph classes that support tetrahedralization using TetGen.
+    """
+
+    # FEM specific
+    order: int = 1
+
+    # Volumetric mesh entity
+    mindihedral: int = 10
+    minratio: float = 1.1
+    nobisect: bool = True
+    quality: bool = True
+    maxvolume: float = -1.0
+    verbose: int = 0
+
+    force_retet: bool = False
+
+
 @gs.assert_initialized
 class Morph(Options):
     """
@@ -126,7 +145,7 @@ class Primitive(Morph):
     fixed: bool = False
 
 
-class Box(Primitive):
+class Box(Primitive, TetGenMixin):
     """
     Morph defined by a box shape.
 
@@ -156,6 +175,22 @@ class Box(Primitive):
         Whether this morph, if created as `RigidEntity`, requires jacobian and inverse kinematics. Defaults to False. **This is only used for RigidEntity.**
     fixed : bool, optional
         Whether the baselink of the entity should be fixed. Defaults to False. **This is only used for RigidEntity.**
+    order : int, optional
+        The order of the FEM mesh. Defaults to 1. **This is only used for FEMEntity.**
+    mindihedral : int, optional
+        The minimum dihedral angle in degrees during tetraheralization. Defaults to 10. **This is only used for Volumetric Entity that requires tetraheralization.**
+    minratio : float, optional
+        The minimum tetrahedron quality ratio during tetraheralization. Defaults to 1.1. **This is only used for Volumetric Entity that requires tetraheralization.**
+    nobisect : bool, optional
+        Whether to disable bisection during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    quality : bool, optional
+        Whether to improve quality during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    maxvolume : float, optional
+        The maximum tetrahedron volume. Defaults to -1.0 (no limit). **This is only used for Volumetric Entity that requires tetraheralization.**
+    verbose : int, optional
+        The verbosity level during tetraheralization. Defaults to 0. **This is only used for Volumetric Entity that requires tetraheralization.**
+    force_retet : bool, optional
+        Whether to force re-tetraheralization. Defaults to False. **This is only used for Volumetric Entity that requires tetraheralization.**
     """
 
     lower: Optional[tuple] = None
@@ -180,7 +215,7 @@ def __init__(self, **data):
                 gs.raise_exception("Invalid lower and upper corner.")
 
 
-class Cylinder(Primitive):
+class Cylinder(Primitive, TetGenMixin):
     """
     Morph defined by a cylinder shape.
 
@@ -204,13 +239,29 @@ class Cylinder(Primitive):
         Whether this morph, if created as `RigidEntity`, requires jacobian and inverse kinematics. Defaults to False. **This is only used for RigidEntity.**
     fixed : bool, optional
         Whether the baselink of the entity should be fixed. Defaults to False. **This is only used for RigidEntity.**
+    order : int, optional
+        The order of the FEM mesh. Defaults to 1. **This is only used for FEMEntity.**
+    mindihedral : int, optional
+        The minimum dihedral angle in degrees during tetraheralization. Defaults to 10. **This is only used for Volumetric Entity that requires tetraheralization.**
+    minratio : float, optional
+        The minimum tetrahedron quality ratio during tetraheralization. Defaults to 1.1. **This is only used for Volumetric Entity that requires tetraheralization.**
+    nobisect : bool, optional
+        Whether to disable bisection during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    quality : bool, optional
+        Whether to improve quality during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    maxvolume : float, optional
+        The maximum tetrahedron volume. Defaults to -1.0 (no limit). **This is only used for Volumetric Entity that requires tetraheralization.**
+    verbose : int, optional
+        The verbosity level during tetraheralization. Defaults to 0. **This is only used for Volumetric Entity that requires tetraheralization.**
+    force_retet : bool, optional
+        Whether to force re-tetraheralization. Defaults to False. **This is only used for Volumetric Entity that requires tetraheralization.**
     """
 
     height: float = 1.0
     radius: float = 0.5
 
 
-class Sphere(Primitive):
+class Sphere(Primitive, TetGenMixin):
     """
     Morph defined by a sphere shape.
 
@@ -232,6 +283,22 @@ class Sphere(Primitive):
         Whether this morph, if created as `RigidEntity`, requires jacobian and inverse kinematics. Defaults to False. **This is only used for RigidEntity.**
     fixed : bool, optional
         Whether the baselink of the entity should be fixed. Defaults to False. **This is only used for RigidEntity.**
+    order : int, optional
+        The order of the FEM mesh. Defaults to 1. **This is only used for FEMEntity.**
+    mindihedral : int, optional
+        The minimum dihedral angle in degrees during tetraheralization. Defaults to 10. **This is only used for Volumetric Entity that requires tetraheralization.**
+    minratio : float, optional
+        The minimum tetrahedron quality ratio during tetraheralization. Defaults to 1.1. **This is only used for Volumetric Entity that requires tetraheralization.**
+    nobisect : bool, optional
+        Whether to disable bisection during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    quality : bool, optional
+        Whether to improve quality during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    maxvolume : float, optional
+        The maximum tetrahedron volume. Defaults to -1.0 (no limit). **This is only used for Volumetric Entity that requires tetraheralization.**
+    verbose : int, optional
+        The verbosity level during tetraheralization. Defaults to 0. **This is only used for Volumetric Entity that requires tetraheralization.**
+    force_retet : bool, optional
+        Whether to force re-tetraheralization. Defaults to False. **This is only used for Volumetric Entity that requires tetraheralization.**
     """
 
     radius: float = 0.5
@@ -391,7 +458,7 @@ def _repr_type(self):
         return f"<gs.morphs.{self.__class__.__name__}(file='{self.file}')>"
 
 
-class Mesh(FileMorph):
+class Mesh(FileMorph, TetGenMixin):
     """
     Morph loaded from a mesh file.
 
@@ -462,6 +529,8 @@ class Mesh(FileMorph):
         Whether to disable bisection during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
     quality : bool, optional
         Whether to improve quality during tetraheralization. Defaults to True. **This is only used for Volumetric Entity that requires tetraheralization.**
+    maxvolume : float, optional
+        The maximum tetrahedron volume. Defaults to -1.0 (no limit). **This is only used for Volumetric Entity that requires tetraheralization.**
     verbose : int, optional
         The verbosity level during tetraheralization. Defaults to 0. **This is only used for Volumetric Entity that requires tetraheralization.**
     force_retet : bool, optional
@@ -476,18 +545,6 @@ class Mesh(FileMorph):
     group_by_material: bool = True
     merge_submeshes_for_collision: bool = True
 
-    # FEM specific
-    order: int = 1
-
-    # Volumetric mesh entity
-    mindihedral: int = 10
-    minratio: float = 1.1
-    nobisect: bool = True
-    quality: bool = True
-    verbose: int = 0
-
-    force_retet: bool = False
-
 
 class MeshSet(Mesh):
     files: List[Any] = []
diff --git a/genesis/utils/mesh.py b/genesis/utils/mesh.py
index 9c7719c5e..cea2442c0 100644
--- a/genesis/utils/mesh.py
+++ b/genesis/utils/mesh.py
@@ -815,12 +815,59 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    if not isinstance(morph, gs.options.morphs.TetGenMixin):
+        raise TypeError(
+            f"Expected an instance of a class that inherits from TetGenMixin, but got an instance of {type(morph).name}."
+        )
+    return dict(
+        order=morph.order,
+        mindihedral=morph.mindihedral,
+        minratio=morph.minratio,
+        nobisect=morph.nobisect,
+        quality=morph.quality,
+        maxvolume=morph.maxvolume,
+        verbose=morph.verbose,
+    )
+
+
+def make_tetgen_switches(cfg):
+    """Build a TetGen switches string from a config dict."""
+    flags = ["p"]
+
+    if cfg.get("quality", True):
+        r = cfg.get("minratio", 1.1)
+        di = cfg.get("mindihedral", 10)
+        flags.append(f"q{r}/{di}")
+
+    a = cfg.get("maxvolume", -1.0)
+    if a > 0:
+        flags.append(f"a{a}")
+
+    o = cfg.get("order", 1)
+    if o != 1:
+        flags.append(f"o{o}")
+
+    if cfg.get("nobisect", False):
+        flags.append("Y")
+
+    v = cfg.get("verbose", 0)
+    if v > 0:
+        flags.append("V" * v)
+
+    return "".join(flags)
+
+
 def tetrahedralize_mesh(mesh, tet_cfg):
     pv_obj = pv.PolyData(
         mesh.vertices, np.concatenate([np.full((mesh.faces.shape[0], 1), mesh.faces.shape[1]), mesh.faces], axis=1)
     )
     tet = tetgen.TetGen(pv_obj)
-    verts, elems = tet.tetrahedralize(**tet_cfg)
+    # Build and apply the switches string directly, since
+    # the Python wrapper sometimes ignores certain kwargs
+    # (e.g. maxvolume). See: https://github.com/pyvista/tetgen/issues/24
+    switches = make_tetgen_switches(tet_cfg)
+    verts, elems = tet.tetrahedralize(switches=switches)
     # visualize_tet(tet, pv_obj, show_surface=False, plot_cell_qual=False)
     return verts, elems
 
diff --git a/tests/conftest.py b/tests/conftest.py
index 1b2525b69..87eab981a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -260,3 +260,48 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture(scope="session")
+def cube_verts_and_faces():
+    cx, cy, cz = (0.0, 0.0, 0.0)
+    edge_length = 1.0
+
+    h = edge_length / 2.0
+
+    verts = [
+        (cx - h, cy - h, cz - h),  # v0
+        (cx + h, cy - h, cz - h),  # v1
+        (cx + h, cy + h, cz - h),  # v2
+        (cx - h, cy + h, cz - h),  # v3
+        (cx - h, cy - h, cz + h),  # v4
+        (cx + h, cy - h, cz + h),  # v5
+        (cx + h, cy + h, cz + h),  # v6
+        (cx - h, cy + h, cz + h),  # v7
+    ]
+
+    faces = [
+        (1, 2, 3, 4),
+        (5, 6, 7, 8),
+        (1, 2, 6, 5),
+        (2, 3, 7, 6),
+        (3, 4, 8, 7),
+        (4, 1, 5, 8),
+    ]
+    return verts, faces
+
+
+@pytest.fixture(scope="session")
+def box_obj_path(asset_tmp_path, cube_verts_and_faces):
+    """Fixture that generates a temporary cube .obj file"""
+    verts, faces = cube_verts_and_faces
+
+    filename = str(asset_tmp_path / f"fixture_box_obj_path.obj")
+    with open(filename, "w", encoding="utf-8") as f:
+        for x, y, z in verts:
+            f.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
+        f.write("\n")
+        for a, b, c, d in faces:
+            f.write(f"f {a} {b} {c} {d}\n")
+
+    return filename
diff --git a/tests/test_fem.py b/tests/test_fem.py
index b88068fd1..512aab11e 100644
--- a/tests/test_fem.py
+++ b/tests/test_fem.py
@@ -57,96 +57,26 @@ def test_multiple_fem_entities(fem_material, show_viewer):
 
 
 @pytest.mark.parametrize("backend", [gs.cpu])
-def test_interior_tetrahedralized_vertex(fem_material, show_viewer, tmp_path):
+def test_interior_tetrahedralized_vertex(fem_material, show_viewer, box_obj_path, cube_verts_and_faces):
     """
-    Test tetrahedralization of a FEM entity with a mesh shape that introduces
+    Test tetrahedralization of a FEM entity with a small maxvolume value that introduces
     internal vertices during tetrahedralization:
       1. Verify all surface vertices lie exactly on the original quad faces of the mesh.
       2. Ensure the visualizer's mesh triangles match the FEM entity's surface triangles.
     """
-
-    def _write_extruded_box(center, large_length, small_length, filename):
-        cx, cy, cz = center
-        hL = large_length / 2.0
-        hl = small_length / 2.0
-
-        z0 = cz + hL
-        z1 = z0 + small_length
-
-        verts = [
-            # Vertices of large cube
-            (cx - hL, cy - hL, cz - hL),  # v1
-            (cx + hL, cy - hL, cz - hL),  # v2
-            (cx + hL, cy + hL, cz - hL),  # v3
-            (cx - hL, cy + hL, cz - hL),  # v4
-            (cx - hL, cy - hL, cz + hL),  # v5
-            (cx + hL, cy - hL, cz + hL),  # v6
-            (cx + hL, cy + hL, cz + hL),  # v7
-            (cx - hL, cy + hL, cz + hL),  # v8
-            # Vertices of a extruded small cube on +Z surface of the large cube
-            (cx - hl, cy - hl, z0),  # v9
-            (cx + hl, cy - hl, z0),  # v10
-            (cx + hl, cy + hl, z0),  # v11
-            (cx - hl, cy + hl, z0),  # v12
-            # Other vertices of the extruded small cube
-            (cx - hl, cy - hl, z1),  # v13
-            (cx + hl, cy - hl, z1),  # v14
-            (cx + hl, cy + hl, z1),  # v15
-            (cx - hl, cy + hl, z1),  # v16
-        ]
-
-        faces = []
-
-        # Large cube
-        faces += [
-            (1, 2, 3, 4),  # -Z
-            (1, 2, 6, 5),  # -Y
-            (2, 3, 7, 6),  # +X
-            (3, 4, 8, 7),  # +Y
-            (4, 1, 5, 8),  # -X
-        ]
-
-        # Large cube (+Z)
-        faces += [
-            (5, 6, 10, 9),
-            (6, 7, 11, 10),
-            (7, 8, 12, 11),
-            (8, 5, 9, 12),
-        ]
-
-        # Small cube (+Z)
-        faces += [(9, 10, 14, 13), (10, 11, 15, 14), (11, 12, 16, 15), (12, 9, 13, 16), (13, 14, 15, 16)]
-
-        # Write obj file
-        with open(filename, "w", encoding="utf-8") as f:
-            for x, y, z in verts:
-                f.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
-            f.write("\n")
-            for face in faces:
-                f.write("f " + " ".join(str(idx) for idx in face) + "\n")
-
-        return verts, faces
+    verts, faces = cube_verts_and_faces
 
     scene = gs.Scene(
-        sim_options=gs.options.SimOptions(),
-        fem_options=gs.options.FEMOptions(),
         show_viewer=show_viewer,
     )
 
-    obj_path = tmp_path / "fem.obj"
-    verts, faces = _write_extruded_box(
-        center=[0.0, 0.0, 0.0],
-        large_length=1.0,
-        small_length=0.1,
-        filename=str(obj_path),
-    )
-
     fem = scene.add_entity(
         morph=gs.morphs.Mesh(
-            file=str(obj_path),
+            file=box_obj_path,
             nobisect=False,
             minratio=1.5,
             verbose=1,
+            maxvolume=0.01,
         ),
         material=fem_material,
     )
@@ -157,6 +87,9 @@ def _write_extruded_box(center, large_length, small_length, filename):
     vertices = state.pos.cpu().numpy()
     surface_indices = np.unique(fem.surface_triangles)
 
+    # Ensure there are interior vertices; this is a prerequisite for this test
+    assert surface_indices.size < vertices.shape[0]
+
     # Verify each surface vertex lies on the original surface mesh
     def _point_on_surface(p, verts, faces, tol=1e-6):
         """Check if point p lies on any of the quad faces (as two triangles)."""
@@ -235,3 +168,28 @@ def _make_triangle_set(verts, faces, tol=4):
         "FEM entity surface triangles and visualizer mesh triangles do not match.\n"
         f"Differences: {set(entity_tris) ^ set(viz_tris)}"
     )
+
+
+@pytest.mark.parametrize("backend", [gs.cpu])
+def test_maxvolume(fem_material, show_viewer, box_obj_path):
+    """Test that imposing a maximum element volume constraint produces a finer mesh (i.e., more elements)."""
+    scene = gs.Scene(
+        show_viewer=show_viewer,
+    )
+
+    # Mesh without any maximum-element-volume constraint
+    fem1 = scene.add_entity(
+        morph=gs.morphs.Mesh(file=box_obj_path, nobisect=False, verbose=1),
+        material=fem_material,
+    )
+
+    # Mesh with maximum element volume limited to 0.01
+    fem2 = scene.add_entity(
+        morph=gs.morphs.Mesh(file=box_obj_path, nobisect=False, maxvolume=0.01, verbose=1),
+        material=fem_material,
+    )
+
+    assert len(fem1.elems) < len(fem2.elems), (
+        f"Mesh with maxvolume=0.01 generated {len(fem2.elems)} elements; "
+        f"expected more than {len(fem1.elems)} elements without a volume limit."
+    )
diff --git a/tests/test_pbd.py b/tests/test_pbd.py
new file mode 100644
index 000000000..51c55433f
--- /dev/null
+++ b/tests/test_pbd.py
@@ -0,0 +1,40 @@
+import uuid
+
+import numpy as np
+import pytest
+
+import genesis as gs
+
+
+@pytest.fixture(scope="session")
+def pbd_material():
+    """Fixture for common FEM material properties"""
+    return gs.materials.PBD.Elastic()
+
+
+@pytest.mark.parametrize("backend", [gs.cpu])
+def test_maxvolume(pbd_material, show_viewer, box_obj_path):
+    """Test that imposing a maximum element volume constraint produces a finer mesh (i.e., more elements)."""
+    scene = gs.Scene(
+        pbd_options=gs.options.PBDOptions(
+            particle_size=0.1,
+        ),
+        show_viewer=show_viewer,
+    )
+
+    # Mesh without any maximum-element-volume constraint
+    pbd1 = scene.add_entity(
+        morph=gs.morphs.Mesh(file=box_obj_path, nobisect=False, verbose=1),
+        material=pbd_material,
+    )
+
+    # Mesh with maximum element volume limited to 0.001
+    pbd2 = scene.add_entity(
+        morph=gs.morphs.Mesh(file=box_obj_path, nobisect=False, maxvolume=0.001, verbose=1),
+        material=pbd_material,
+    )
+
+    assert pbd1.n_elems < pbd2.n_elems, (
+        f"Mesh with maxvolume=0.01 generated {pbd2.n_elems} elements; "
+        f"expected more than {pbd1.n_elems} elements without a volume limit."
+    )


Reviews:
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T08:39:24Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T08:43:27Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:09:50Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:21:08Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:25:51Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T10:58:15Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T11:05:35Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T11:41:06Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T12:59:54Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:01:33Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T13:05:02Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T13:08:00Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:12:49Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T13:20:03Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:27:41Z
user: login: duburcqa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T13:33:53Z
user: login: kosuke1701
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:39:35Z

Review comments:
diff_hunk: @@ -815,12 +815,55 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    return dict(
+        order=getattr(morph, "order", 1),
path: genesis/utils/mesh.py
user: login: duburcqa
body: `getattr` is not a good approach. `morph` must have these attributes, already set with the right default value. Add some `isinstance` check for the morph and raise an exception if its type is not appropriate.
created_at: 2025-04-29T08:39:24Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
path: tests/conftest.py
user: login: duburcqa
body: I think the scope should be "session" as you expect the same output for all tests? Once this is done, you do not need `uuid` anymore, just call it as you want, e.g. `cube.obj`. It will be created only once anyway so it is perfectly fine.
created_at: 2025-04-29T08:41:32Z
author_association: COLLABORATOR
diff_hunk: @@ -235,3 +235,30 @@ def _make_triangle_set(verts, faces, tol=4):
         "FEM entity surface triangles and visualizer mesh triangles do not match.\n"
         f"Differences: {set(entity_tris) ^ set(viz_tris)}"
     )
+
+
+@pytest.mark.parametrize("backend", [gs.cpu])
+def test_maxvolume(fem_material, show_viewer, box_obj_path):
+    """Test that imposing a maximum element volume constraint produces a finer mesh (i.e., more elements)."""
+    scene = gs.Scene(
+        sim_options=gs.options.SimOptions(),
path: tests/test_fem.py
user: login: duburcqa
body: Remove empty options, it is useless.
created_at: 2025-04-29T08:42:00Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
+def box_obj_path(tmp_path):
path: tests/conftest.py
user: login: duburcqa
body: I think you could reuse this fixture in tests that were merged in some of your previous PRs no? To avoid code duplication.
created_at: 2025-04-29T08:43:20Z
author_association: COLLABORATOR
diff_hunk: @@ -815,12 +815,55 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    return dict(
+        order=getattr(morph, "order", 1),
path: genesis/utils/mesh.py
user: login: kosuke1701
body: It seems that only `Mesh` has TetGen-specific attributes such as `order`, while other morph classes (e.g., `Sphere`) do not. I also feel that it is not a good approach. Perhaps, we could move the definition of TetGen-related attributes to the `Morph` root class, so that all morphs has necessary attributes. What do you think?
created_at: 2025-04-29T09:09:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
path: tests/conftest.py
user: login: kosuke1701
body: I set the scope to "function" because the remeshing and tetrahedralization processes seem to have a caching mechanism, and I did not want it to affect the tests. However, I realized that I was mistaken. Since the scope of `tmp_path` is also "function", using a UUID to create different OBJ files for each test is unnecessary. I think we could just remove `uuid` and keep the scope to "function". What do you think?
created_at: 2025-04-29T09:21:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
+def box_obj_path(tmp_path):
path: tests/conftest.py
user: login: kosuke1701
body: The test in my previous PR (`test_interior_tetrahedralized_vertex`) cannot simply use `box_obj_path` because the test requires special shape that introduces interior vertices on tetrahedralization. However, if it is OK to use new `maxvolume` option in the test, we can reuse `box_obj_path` because small `maxvolume` also introduce interior vertices!
created_at: 2025-04-29T09:25:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
+def box_obj_path(tmp_path):
path: tests/conftest.py
user: login: duburcqa
body: I see… do as you think is best!
created_at: 2025-04-29T10:58:14Z
author_association: COLLABORATOR
diff_hunk: @@ -815,12 +815,55 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    return dict(
+        order=getattr(morph, "order", 1),
path: genesis/utils/mesh.py
user: login: duburcqa
body: If these options are also meaningful for primitive geometries, then yes go for it and move these options in base class! But make sure it does not expose the options for morph for which it does not make sense.
created_at: 2025-04-29T11:05:35Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
path: tests/conftest.py
user: login: duburcqa
body: Yes, just do this ! Could you document your findings recording the caching mechanism and why we cannot use "session" scope ? It would avoid changing this scope by mistake.
created_at: 2025-04-29T11:41:06Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +261,42 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture
path: tests/conftest.py
user: login: kosuke1701
body: When I double-checked the implementation, I found that the caching mechanism uses vertex coordinates and other information to generate the cache filenames. Therefore, creating independent OBJ files doesn't really make sense.
```
rm_file_path = mu.get_remesh_path(self.verts, self.faces, edge_len_abs, edge_len_ratio, fix)
tet_file_path = mu.get_tet_path(mesh.vertices, mesh.faces, tet_cfg)
```
I'll update the scope to "session" as you initially suggested!
created_at: 2025-04-29T12:59:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -815,12 +815,55 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    return dict(
+        order=getattr(morph, "order", 1),
path: genesis/utils/mesh.py
user: login: kosuke1701
body: FEMEntity only supports Sphere, Box, Cylinder, and Mesh, while PBD3DEntity does not specify which morph class it supports. I'm not sure exactly which morph should be supported, but it seems those morphs supported by FEMEntity are enough to me. Other morphs are either 2D or for RigidEntity and DroneEntity.

I'll create a mixin to add options only to Sphere, Box, Cylinder, and Mesh.
created_at: 2025-04-29T13:01:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -815,12 +815,55 @@ def create_plane(size=1e3, color=None, normal=(0, 0, 1)):
     return mesh
 
 
+def generate_tetgen_config_from_morph(morph):
+    return dict(
+        order=getattr(morph, "order", 1),
path: genesis/utils/mesh.py
user: login: duburcqa
body: Sounds good!
created_at: 2025-04-29T13:05:02Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +260,49 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture(scope="session")
+def cube_verts_and_faces():
+    cx, cy, cz = (0.0, 0.0, 0.0)
+    edge_length = 1.0
+
+    h = edge_length / 2.0
+
+    verts = [
+        (cx - h, cy - h, cz - h),  # v0
+        (cx + h, cy - h, cz - h),  # v1
+        (cx + h, cy + h, cz - h),  # v2
+        (cx - h, cy + h, cz - h),  # v3
+        (cx - h, cy - h, cz + h),  # v4
+        (cx + h, cy - h, cz + h),  # v5
+        (cx + h, cy + h, cz + h),  # v6
+        (cx - h, cy + h, cz + h),  # v7
+    ]
+
+    faces = [
+        (1, 2, 3, 4),
+        (5, 6, 7, 8),
+        (1, 2, 6, 5),
+        (2, 3, 7, 6),
+        (3, 4, 8, 7),
+        (4, 1, 5, 8),
+    ]
+    return verts, faces
+
+
+@pytest.fixture(scope="session")
+def box_obj_path(tmp_path_factory, cube_verts_and_faces):
+    """Fixture that generates a temporary cube .obj file"""
+    verts, faces = cube_verts_and_faces
+
+    tmp_path = tmp_path_factory.mktemp("obj_fixtures")
+    filename = str(tmp_path / f"fixture_box_obj_path.obj")
path: tests/conftest.py
user: login: duburcqa
body: Do you really need the factory for this? Instead of just `tmp_path`?
created_at: 2025-04-29T13:07:59Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +260,49 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture(scope="session")
+def cube_verts_and_faces():
+    cx, cy, cz = (0.0, 0.0, 0.0)
+    edge_length = 1.0
+
+    h = edge_length / 2.0
+
+    verts = [
+        (cx - h, cy - h, cz - h),  # v0
+        (cx + h, cy - h, cz - h),  # v1
+        (cx + h, cy + h, cz - h),  # v2
+        (cx - h, cy + h, cz - h),  # v3
+        (cx - h, cy - h, cz + h),  # v4
+        (cx + h, cy - h, cz + h),  # v5
+        (cx + h, cy + h, cz + h),  # v6
+        (cx - h, cy + h, cz + h),  # v7
+    ]
+
+    faces = [
+        (1, 2, 3, 4),
+        (5, 6, 7, 8),
+        (1, 2, 6, 5),
+        (2, 3, 7, 6),
+        (3, 4, 8, 7),
+        (4, 1, 5, 8),
+    ]
+    return verts, faces
+
+
+@pytest.fixture(scope="session")
+def box_obj_path(tmp_path_factory, cube_verts_and_faces):
+    """Fixture that generates a temporary cube .obj file"""
+    verts, faces = cube_verts_and_faces
+
+    tmp_path = tmp_path_factory.mktemp("obj_fixtures")
+    filename = str(tmp_path / f"fixture_box_obj_path.obj")
path: tests/conftest.py
user: login: kosuke1701
body: tmp_path seems to be function-scoped and cannot be used in session scope
created_at: 2025-04-29T13:12:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,3 +260,49 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture(scope="session")
+def cube_verts_and_faces():
+    cx, cy, cz = (0.0, 0.0, 0.0)
+    edge_length = 1.0
+
+    h = edge_length / 2.0
+
+    verts = [
+        (cx - h, cy - h, cz - h),  # v0
+        (cx + h, cy - h, cz - h),  # v1
+        (cx + h, cy + h, cz - h),  # v2
+        (cx - h, cy + h, cz - h),  # v3
+        (cx - h, cy - h, cz + h),  # v4
+        (cx + h, cy - h, cz + h),  # v5
+        (cx + h, cy + h, cz + h),  # v6
+        (cx - h, cy + h, cz + h),  # v7
+    ]
+
+    faces = [
+        (1, 2, 3, 4),
+        (5, 6, 7, 8),
+        (1, 2, 6, 5),
+        (2, 3, 7, 6),
+        (3, 4, 8, 7),
+        (4, 1, 5, 8),
+    ]
+    return verts, faces
+
+
+@pytest.fixture(scope="session")
+def box_obj_path(tmp_path_factory, cube_verts_and_faces):
+    """Fixture that generates a temporary cube .obj file"""
+    verts, faces = cube_verts_and_faces
+
+    tmp_path = tmp_path_factory.mktemp("obj_fixtures")
+    filename = str(tmp_path / f"fixture_box_obj_path.obj")
path: tests/conftest.py
user: login: duburcqa
body: you should use `asset_tmp_path`
created_at: 2025-04-29T13:20:03Z
author_association: COLLABORATOR
diff_hunk: @@ -260,3 +260,49 @@ def gs_sim(
     scene.build()
 
     return gs_sim
+
+
+@pytest.fixture(scope="session")
+def cube_verts_and_faces():
+    cx, cy, cz = (0.0, 0.0, 0.0)
+    edge_length = 1.0
+
+    h = edge_length / 2.0
+
+    verts = [
+        (cx - h, cy - h, cz - h),  # v0
+        (cx + h, cy - h, cz - h),  # v1
+        (cx + h, cy + h, cz - h),  # v2
+        (cx - h, cy + h, cz - h),  # v3
+        (cx - h, cy - h, cz + h),  # v4
+        (cx + h, cy - h, cz + h),  # v5
+        (cx + h, cy + h, cz + h),  # v6
+        (cx - h, cy + h, cz + h),  # v7
+    ]
+
+    faces = [
+        (1, 2, 3, 4),
+        (5, 6, 7, 8),
+        (1, 2, 6, 5),
+        (2, 3, 7, 6),
+        (3, 4, 8, 7),
+        (4, 1, 5, 8),
+    ]
+    return verts, faces
+
+
+@pytest.fixture(scope="session")
+def box_obj_path(tmp_path_factory, cube_verts_and_faces):
+    """Fixture that generates a temporary cube .obj file"""
+    verts, faces = cube_verts_and_faces
+
+    tmp_path = tmp_path_factory.mktemp("obj_fixtures")
+    filename = str(tmp_path / f"fixture_box_obj_path.obj")
path: tests/conftest.py
user: login: kosuke1701
body: Oh, I missed the fixture. Thank you for pointing it out!
created_at: 2025-04-29T13:27:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,17 +144,16 @@ def remesh(self, edge_len_abs=None, edge_len_ratio=0.01, fix=True):
         )
         self.clear_visuals()
 
-    def tetrahedralize(self, order, mindihedral, minratio, nobisect, quality, verbose):
+    def tetrahedralize(self, tet_cfg):
path: genesis/engine/mesh.py
user: login: duburcqa
body: Did you make sure the new API for `tetrahedralize` is used everywhere in the codebase? 
created_at: 2025-04-29T13:33:53Z
author_association: COLLABORATOR
diff_hunk: @@ -144,17 +144,16 @@ def remesh(self, edge_len_abs=None, edge_len_ratio=0.01, fix=True):
         )
         self.clear_visuals()
 
-    def tetrahedralize(self, order, mindihedral, minratio, nobisect, quality, verbose):
+    def tetrahedralize(self, tet_cfg):
path: genesis/engine/mesh.py
user: login: kosuke1701
body: Yes. Actually, it is used only in PBD3DEntity.
created_at: 2025-04-29T13:39:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: duburcqa
created_at: 2025-04-29T08:44:15Z
body: Thank you for your contribution once again! And caring about unit test, this is very important 👍 
author_association: COLLABORATOR
user: login: kosuke1701
created_at: 2025-04-29T09:59:11Z
body: I revised some parts accoring to the discussions above:

* Remove empty options and uuid from the tests.
* Change the test of my previous PR so that it uses `maxvolume` option and reuses `box_obj_path` fixture.
* Move the definition of TetGen-related options from `Mesh` to `Morph` and directly access them as attributes.

Please feel free to make any additional feedbacks!
author_association: CONTRIBUTOR
user: login: duburcqa
created_at: 2025-04-29T13:52:21Z
body: Let's merge once the CI passes.
author_association: COLLABORATOR