Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions genesis/engine/entities/base_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ def material(self):
def is_built(self):
return self._solver._scene._is_built

def _repr_brief(self):
return f"{self.__repr_name__()}, idx: {self.idx}, morph: {self._repr_morph()}, material: {self.material}"

def _repr_morph(self):
return f"{self.morph}"

@property
def name(self) -> str:
"""
Expand Down
49 changes: 6 additions & 43 deletions genesis/engine/entities/rigid_entity/rigid_entity.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import inspect
import os
import xml.etree.ElementTree as ET
from itertools import chain
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Any, NamedTuple, Sequence
from functools import wraps

Expand Down Expand Up @@ -1914,47 +1912,7 @@ def zero_all_dofs_velocity(self, envs_idx=None, *, skip_forward=False):
def _get_morph_identifier(self) -> str:
if self._enable_heterogeneous:
return "heterogeneous"

morph = self._morph

if isinstance(morph, gs.morphs.Box):
return "box"
if isinstance(morph, gs.morphs.Sphere):
return "sphere"
if isinstance(morph, gs.morphs.Cylinder):
return "cylinder"
if isinstance(morph, gs.morphs.Plane):
return "plane"
if isinstance(morph, gs.morphs.Mesh):
return Path(morph.file).stem
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.MJCF):
if isinstance(morph.file, str):
# Try to get model name from MJCF file, fall back to filename stem
model_name = mju.get_model_name(morph.file)
if model_name:
return model_name
return Path(morph.file).stem
return morph.file.name
if isinstance(morph, gs.morphs.Drone):
if isinstance(morph.file, str):
return Path(morph.file).stem
return morph.file.name
if isinstance(morph, gs.morphs.USD):
if morph.prim_path:
return morph.prim_path.rstrip("/").split("/")[-1]
return Path(morph.file).stem
if isinstance(morph, gs.morphs.Terrain):
return morph.name if morph.name else "terrain"
return "rigid"
return self._morph._identifier()

# ------------------------------------------------------------------------------------
# ----------------------------------- properties -------------------------------------
Expand Down Expand Up @@ -2003,6 +1961,11 @@ def morphs(self):
"""All morphs of the entity (main morph + heterogeneous variants if any)."""
return gs.List((self._morph, *self._morph_heterogeneous))

def _repr_morph(self):
if self._enable_heterogeneous:
return f"{len(self.morphs)} morph variants"
return f"{self.main_morph}"

@property
def n_joints(self):
"""The number of `RigidJoint` in the entity."""
Expand Down
3 changes: 3 additions & 0 deletions genesis/engine/entities/rigid_entity/rigid_equality.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def idx(self):
"""
return self._idx

def _repr_brief(self):
return f"{self.__repr_name__()}, idx: {self.idx}"

@property
def idx_local(self):
"""
Expand Down
50 changes: 47 additions & 3 deletions genesis/options/morphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import os
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Annotated, Any, ClassVar, Literal
from typing_extensions import Self

Expand All @@ -15,6 +16,7 @@

import genesis as gs
import genesis.utils.geom as gu
import genesis.utils.mjcf as mju
import genesis.utils.misc as mu
import genesis.utils.urdf as uu
import genesis.ext.urdfpy as urdfpy
Expand Down Expand Up @@ -148,6 +150,10 @@ def model_post_init(self, context: Any) -> None:
if not self.visualization and not self.collision:
gs.raise_exception("`visualization` and `collision` cannot both be False.")

def _identifier(self) -> str:
# Short identifier used for entity naming and brief repr; defaults to the morph type name.
return type(self).__name__.lower()


############################ Nowhere ############################
class Nowhere(Morph):
Expand Down Expand Up @@ -578,7 +584,9 @@ class FileMorph(Morph):
**This is only used for RigidEntity.**
"""

file: Any = ""
# Shown in the repr header via __repr_name__ (a bounded identifier for in-memory descriptions), so it is kept out
# of the field listing to avoid both duplicating it and dumping a whole inline document.
file: Any = Field(default="", repr=False)
scale: Annotated[tuple[PositiveFloat, PositiveFloat, PositiveFloat], Field(strict=False)] | PositiveFloat = 1.0
decimate: StrictBool | None = None
decimate_face_num: PositiveInt = 500
Expand Down Expand Up @@ -651,8 +659,23 @@ def __init__(
if scale.ndim > 1 or scale.size not in (1, 3):
gs.raise_exception("`scale` should be a scalar sequence of length 1 or 3.")

def _identifier(self) -> str:
file = self.file
if not isinstance(file, str):
return file.name
if os.path.exists(file):
return Path(file).stem
# An in-memory description has no filename to fall back on; subclasses that embed a name (MJCF model,
# URDF robot) override this, otherwise the morph type name stands in for the document.
return super()._identifier()

def __repr_name__(self):
return f"{super().__repr_name__()[:-1]}(file='{self.file}')>"
# A real file path is shown verbatim; an MJCF/URDF built in memory has no path on disk, so a bounded
# identifier stands in for the document rather than dumping it.
file = self.file
if isinstance(file, str) and not os.path.exists(file):
file = f"<inline {self._identifier()}>"
return f"{super().__repr_name__()[:-1]}(file='{file}')>"

def is_format(self, format):
if not isinstance(self.file, (str, os.PathLike)):
Expand Down Expand Up @@ -957,6 +980,11 @@ def model_post_init(self, context: Any) -> None:
if not is_inline_xml and not self.is_format(MJCF_FORMAT):
gs.raise_exception(f"Expected `{MJCF_FORMAT}` extension for MJCF file: {self.file}")

def _identifier(self) -> str:
if isinstance(self.file, str) and (name := mju.get_model_name(self.file)):
return name
return super()._identifier()


class URDF(FileMorph):
"""
Expand Down Expand Up @@ -1094,6 +1122,14 @@ def is_format(self, format):
return format == URDF_FORMAT
return super().is_format(format)

def _identifier(self) -> str:
if isinstance(self.file, str):
try:
return uu.get_robot_name(self.file)
except (ValueError, ET.ParseError, FileNotFoundError, OSError):
pass
return super()._identifier()


class Drone(FileMorph):
"""
Expand Down Expand Up @@ -1384,6 +1420,9 @@ def model_post_init(self, context: Any) -> None:
):
gs.raise_exception("`subterrain_size` should be divisible by `horizontal_scale`.")

def _identifier(self) -> str:
return self.name if self.name else super()._identifier()

@property
def default_params(self):
return {
Expand Down Expand Up @@ -1644,5 +1683,10 @@ def __init__(self, **data):

self.usd_ctx = UsdContext(self.file)

def _identifier(self) -> str:
if self.prim_path:
return self.prim_path.rstrip("/").split("/")[-1]
return super()._identifier()

def __repr_name__(self):
return f"{super().__repr_name__()[:-1]}(file='{self.file}', prim_path='{self.prim_path}')>"
return f"{super().__repr_name__()[:-1]}, prim_path='{self.prim_path}')>"
18 changes: 4 additions & 14 deletions genesis/repr_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,12 @@ def __repr_name__(self):
return f"<gs.{'.'.join((*submodule, class_name))}>"

def _repr_briefer(self):
repr_str = self.__repr_name__()
if hasattr(self, "id"):
repr_str += f"(id={self.id})"
return repr_str
return self.__repr_name__()

def _repr_brief(self):
repr_str = self.__repr_name__()
if hasattr(self, "id"):
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 hasattr(self, "material"):
repr_str += f", material: {self.material}"
return repr_str
# Subclasses carrying brief-worthy state (entities, indexed sub-objects) override this to append their own
# fields. The base rendering is just the class name.
return self.__repr_name__()

def __repr__(self) -> str:
# Detect if running under a debugger (VSCode or PyCharm)
Expand Down
10 changes: 6 additions & 4 deletions genesis/utils/urdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def get_robot_name(file_path):
Parameters
----------
file_path : str or Path
Path to the URDF file.
Path to the URDF file, or inline URDF XML content.

Returns
-------
Expand All @@ -40,9 +40,11 @@ def get_robot_name(file_path):
ValueError
If the robot name attribute is missing or empty.
"""
path = os.path.join(get_assets_dir(), file_path)
tree = ET.parse(path)
root = tree.getroot()
try:
# Inline XML content parses directly; a file path does not and falls back to reading from disk.
root = ET.fromstring(file_path)
except ET.ParseError:
root = ET.parse(os.path.join(get_assets_dir(), file_path)).getroot()
if root.tag == "robot":
name = root.attrib.get("name")
if name:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,59 @@ def test_coacd_options_pca_validation():
gs.options.CoacdOptions(pca=True)


@pytest.mark.required
def test_repr_does_not_crash():
inline_mjcf = '<mujoco model="probe"><worldbody><body><geom type="box" size="1 1 1"/></body></worldbody></mujoco>'

scene = gs.Scene(show_viewer=False)
scene.add_entity(morph=gs.morphs.Plane())
scene.add_entity(morph=gs.morphs.Box(size=(0.1, 0.1, 0.1)))
panda = scene.add_entity(morph=gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml"))
inline = scene.add_entity(morph=gs.morphs.MJCF(file=inline_mjcf))
het = scene.add_entity(
morph=(
gs.morphs.Box(size=(0.2, 0.2, 0.2)),
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(),
)
cam = scene.add_camera(
res=(64, 64),
pos=(1.0, 1.0, 1.0),
lookat=(0.0, 0.0, 0.0),
)
scene.build(n_envs=2)

# Every printable object renders without raising, across both the brief and the full colorized form.
for obj in (scene, scene.entities, cam, scene.sim.rigid_solver):
assert repr(obj)
for entity in scene.entities:
assert entity._repr_brief()
assert repr(entity)
for morph in entity.morphs:
assert repr(morph)
sub_objects = [*entity.links, *entity.joints, *entity.vgeoms]
if isinstance(entity, gs.engine.entities.RigidEntity):
sub_objects += list(entity.geoms)
for sub in sub_objects:
assert sub._repr_brief()
assert repr(sub)

# Sanity on the parts worth enforcing.
# A file-based morph shows its path; an in-memory description is identified by its model name, not dumped.
assert "panda.xml" in repr(panda.main_morph)
assert "<inline probe>" in inline.main_morph.__repr_name__()
assert inline_mjcf not in repr(inline.main_morph)
# A heterogeneous entity reports its variants instead of collapsing to a single ambiguous morph.
assert "morph variants" in het._repr_brief()


@pytest.mark.required
def test_scene_destroy_cleans_up_simulator():
scene = gs.Scene(show_viewer=False)
Expand Down
Loading