diff --git a/genesis/engine/entities/rigid_entity/rigid_entity.py b/genesis/engine/entities/rigid_entity/rigid_entity.py index 5fe9baf45..0a14737d5 100644 --- a/genesis/engine/entities/rigid_entity/rigid_entity.py +++ b/genesis/engine/entities/rigid_entity/rigid_entity.py @@ -1821,6 +1821,16 @@ def n_links(self): """The number of `RigidLink` in the entity.""" return len(self._links) + @property + def morph(self): + """The morph of the entity.""" + if self._enable_heterogeneous: + gs.raise_exception( + "Heterogeneous entities have multiple morph variants. Use `.morphs` for all variants, " + "or `.main_morph` only when explicitly using the first variant." + ) + return self._morph + @property def main_morph(self): """The main morph of the entity (first morph for heterogeneous entities).""" diff --git a/genesis/ext/pyrender/overlay/plugin.py b/genesis/ext/pyrender/overlay/plugin.py index 734b54d16..cae0ecc92 100644 --- a/genesis/ext/pyrender/overlay/plugin.py +++ b/genesis/ext/pyrender/overlay/plugin.py @@ -352,7 +352,8 @@ def _capture_pending_entities_kwargs(self): editor panel. Keyed by entity name; values are the kwargs forwarded to ``scene.add_entity``.""" self._pending_entities_kwargs = {} for entity in self.scene.entities: - kwargs: dict[str, Any] = {"morph": entity.morph} + morph = tuple(entity.morphs) if getattr(entity, "_enable_heterogeneous", False) else entity.morph + kwargs: dict[str, Any] = {"morph": morph} if isinstance(entity, gs.engine.entities.RigidEntity): kwargs["material"] = entity.material kwargs["surface"] = entity.surface diff --git a/genesis/repr_base.py b/genesis/repr_base.py index 5b9c35271..c9b3533f0 100644 --- a/genesis/repr_base.py +++ b/genesis/repr_base.py @@ -45,8 +45,15 @@ def _repr_brief(self): repr_str += f": {self.id}" if hasattr(self, "idx"): repr_str += f", idx: {self.idx}" - if hasattr(self, "morph"): - repr_str += f", morph: {self.morph}" + if getattr(self, "_enable_heterogeneous", False): + repr_str += f", morphs: {len(self.morphs)} variants" + else: + try: + morph = self.morph + except AttributeError: + pass + else: + repr_str += f", morph: {morph}" if hasattr(self, "material"): repr_str += f", material: {self.material}" return repr_str diff --git a/genesis/vis/rasterizer_context.py b/genesis/vis/rasterizer_context.py index a52ad221e..8ec79ef6d 100644 --- a/genesis/vis/rasterizer_context.py +++ b/genesis/vis/rasterizer_context.py @@ -444,8 +444,8 @@ def on_rigid(self): # For z-axis normal planes, render a single instance shared across all envs to avoid z-fighting, # unless they do not overlap. env_shared = not self.env_separate_rigid - if not env_shared and isinstance(entity.morph, gs.morphs.Plane): - plane_normal, plane_size = entity.morph.normal, entity.morph.plane_size + if not env_shared and isinstance(entity.main_morph, gs.morphs.Plane): + plane_normal, plane_size = entity.main_morph.normal, entity.main_morph.plane_size if ( abs(plane_normal[0]) < gs.EPS and abs(plane_normal[1]) < gs.EPS @@ -562,8 +562,8 @@ def update_rigid(self): geom_T = geoms_T[geom.idx][geom_envs_idx] # Keep single-instance for z-axis normal planes (see on_rigid) - if isinstance(entity.morph, gs.morphs.Plane): - plane_normal, plane_size = entity.morph.normal, entity.morph.plane_size + if isinstance(entity.main_morph, gs.morphs.Plane): + plane_normal, plane_size = entity.main_morph.normal, entity.main_morph.plane_size if ( abs(plane_normal[0]) < gs.EPS and abs(plane_normal[1]) < gs.EPS diff --git a/tests/test_imgui_overlay.py b/tests/test_imgui_overlay.py index 7812d01b9..d4625d0d5 100644 --- a/tests/test_imgui_overlay.py +++ b/tests/test_imgui_overlay.py @@ -16,6 +16,45 @@ _IMGUI_BUNDLE_AVAILABLE = False +@pytest.mark.required +def test_imgui_overlay_capture_pending_entities_preserves_heterogeneous_morphs(): + scene = gs.Scene(show_viewer=False) + single_morph = gs.morphs.Box(size=(0.1, 0.1, 0.1)) + single_entity = scene.add_entity( + morph=single_morph, + visualize_contact=True, + name="single", + ) + heterogeneous_morphs = ( + gs.morphs.Box(size=(0.2, 0.2, 0.2)), + gs.morphs.Cylinder(radius=0.05, height=0.2), + ) + heterogeneous_entity = scene.add_entity( + morph=heterogeneous_morphs, + visualize_contact=True, + name="heterogeneous", + ) + + plugin = ImGuiOverlayPlugin.__new__(ImGuiOverlayPlugin) + plugin.scene = scene + + plugin._capture_pending_entities_kwargs() + + single_kwargs = plugin.pending_entities_kwargs[single_entity.name] + assert single_kwargs["morph"] is single_morph + assert single_kwargs["material"] is single_entity.material + assert single_kwargs["surface"] is single_entity.surface + assert single_kwargs["visualize_contact"] is True + + heterogeneous_kwargs = plugin.pending_entities_kwargs[heterogeneous_entity.name] + assert heterogeneous_kwargs["morph"] == heterogeneous_morphs + assert heterogeneous_kwargs["morph"][0] is heterogeneous_morphs[0] + assert heterogeneous_kwargs["morph"][1] is heterogeneous_morphs[1] + assert heterogeneous_kwargs["material"] is heterogeneous_entity.material + assert heterogeneous_kwargs["surface"] is heterogeneous_entity.surface + assert heterogeneous_kwargs["visualize_contact"] is True + + def _apply_deterministic_imgui_overrides(monkeypatch): """Make ImGui rendering and timing pixel-identical across renderers for snapshot tests.""" from imgui_bundle import imgui diff --git a/tests/test_render.py b/tests/test_render.py index cc6561816..97e164f24 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -79,6 +79,42 @@ def skip_if_not_installed(renderer_type): pytest.skip(SKIP_NO_LUISA) +@pytest.mark.required +@pytest.mark.parametrize("renderer_type", [RENDERER_TYPE.RASTERIZER]) +def test_rasterizer_context_heterogeneous_main_morph_no_throw(monkeypatch, renderer): + from genesis.vis.rasterizer import Rasterizer + + def _skip_offscreen_renderer_build(self): + self.visualizer = self._context.visualizer + + monkeypatch.setattr(Rasterizer, "build", _skip_offscreen_renderer_build) + + scene = gs.Scene( + renderer=renderer, + vis_options=gs.options.VisOptions( + env_separate_rigid=True, + shadow=False, + ), + show_viewer=False, + ) + scene.add_entity( + morph=( + gs.morphs.Box(size=(0.1, 0.1, 0.1)), + gs.morphs.Cylinder(radius=0.05, height=0.2), + ), + ) + scene.add_entity( + morph=( + gs.morphs.Box(size=(0.2, 0.2, 0.2)), + gs.morphs.Sphere(radius=0.1), + ), + material=gs.materials.Kinematic(), + ) + + scene.build(n_envs=2) + scene.visualizer.context.update(force_render=True) + + @pytest.mark.required @pytest.mark.parametrize( "renderer_type", diff --git a/tests/test_rigid_physics.py b/tests/test_rigid_physics.py index 3cd366a58..9a35ecb5d 100644 --- a/tests/test_rigid_physics.py +++ b/tests/test_rigid_physics.py @@ -5238,6 +5238,45 @@ def test_heterogeneous_invalid_material_raises(): ) +@pytest.mark.required +def test_heterogeneous_morph_property_raises(): + """Test that heterogeneous entities require explicit access through morphs.""" + scene = gs.Scene(show_viewer=False) + + single_morph = gs.morphs.Box(size=(0.1, 0.1, 0.1)) + single_obj = scene.add_entity(morph=single_morph) + + rigid_morphs_heterogeneous = ( + gs.morphs.Box(size=(0.1, 0.1, 0.1)), + gs.morphs.Cylinder(radius=0.05, height=0.2), + ) + rigid_obj = scene.add_entity(morph=rigid_morphs_heterogeneous) + kinematic_morphs_heterogeneous = ( + gs.morphs.Box(size=(0.2, 0.2, 0.2)), + gs.morphs.Sphere(radius=0.1), + ) + kinematic_obj = scene.add_entity( + morph=kinematic_morphs_heterogeneous, + material=gs.materials.Kinematic(), + ) + + assert single_obj.morph is single_morph + assert rigid_obj.main_morph is rigid_morphs_heterogeneous[0] + assert list(rigid_obj.morphs) == list(rigid_morphs_heterogeneous) + with pytest.raises(gs.GenesisException, match=r"Heterogeneous.*\.morphs") as exc_info: + _ = rigid_obj.morph + assert ".main_morph" in str(exc_info.value) + + assert kinematic_obj.main_morph is kinematic_morphs_heterogeneous[0] + assert list(kinematic_obj.morphs) == list(kinematic_morphs_heterogeneous) + with pytest.raises(gs.GenesisException, match=r"Heterogeneous.*\.morphs"): + _ = kinematic_obj.morph + + repr(rigid_obj) + repr(kinematic_obj) + repr(scene.entities) + + @pytest.mark.required def test_heterogeneous_fewer_envs_than_variants(): """Test that having fewer environments than variants works correctly.