"""Test viser conversion functions with various robot models."""
from pathlib import Path
import mujoco
from mjlab.viewer.viser import (
create_site_mesh,
get_geom_texture_id,
group_geoms_by_visual_compat,
merge_geoms,
merge_sites,
mujoco_mesh_to_trimesh,
)
def load_robot_model(robot_name: str) -> mujoco.MjModel:
"""Load a robot model from the asset zoo."""
base_path = Path(__file__).parent.parent / "src/mjlab/asset_zoo/robots"
# Map robot names to their XML files.
robot_paths = {
"unitree_g1": base_path / "unitree_g1/xmls/g1.xml",
"unitree_go1": base_path / "unitree_go1/xmls/go1.xml",
}
if robot_name not in robot_paths:
raise ValueError(f"Unknown robot: {robot_name}")
xml_path = robot_paths[robot_name]
if not xml_path.exists():
raise FileNotFoundError(f"Robot XML not found: {xml_path}")
return mujoco.MjModel.from_xml_path(str(xml_path))
def test_unitree_g1_conversion():
"""Test conversion with Unitree G1 robot."""
model = load_robot_model("unitree_g1")
mesh_geom_count = 0
has_textures = False
for geom_idx in range(model.ngeom):
if model.geom_type[geom_idx] == mujoco.mjtGeom.mjGEOM_MESH:
mesh_geom_count += 1
# Convert to trimesh.
mesh = mujoco_mesh_to_trimesh(model, geom_idx)
# Basic checks.
assert mesh is not None, f"Failed to convert geom {geom_idx}"
assert len(mesh.vertices) > 0, f"Mesh {geom_idx} has no vertices"
assert len(mesh.faces) > 0, f"Mesh {geom_idx} has no faces"
# Check for textures.
if hasattr(mesh.visual, "uv"):
has_textures = True
assert mesh_geom_count > 0, "No mesh geometries found in Unitree G1"
print(f"✓ Unitree G1: Successfully converted {mesh_geom_count} mesh geometries")
if has_textures:
print(" - Found textured meshes")
def test_unitree_go1_conversion():
"""Test conversion with Unitree Go1 robot."""
model = load_robot_model("unitree_go1")
mesh_geom_count = 0
primitive_geom_count = 0
for geom_idx in range(model.ngeom):
geom_type = model.geom_type[geom_idx]
if geom_type == mujoco.mjtGeom.mjGEOM_MESH:
mesh_geom_count += 1
# Convert to trimesh.
mesh = mujoco_mesh_to_trimesh(model, geom_idx)
# Basic checks.
assert mesh is not None, f"Failed to convert geom {geom_idx}"
assert len(mesh.vertices) > 0, f"Mesh {geom_idx} has no vertices"
assert len(mesh.faces) > 0, f"Mesh {geom_idx} has no faces"
else:
# Count primitive geometries (box, sphere, capsule, etc.).
primitive_geom_count += 1
print(f"✓ Unitree Go1: Successfully converted {mesh_geom_count} mesh geometries")
print(f" - Also has {primitive_geom_count} primitive geometries")
def test_texture_extraction():
"""Test texture extraction with a simple textured model."""
# Create a model with procedural texture.
xml_string = """
"""
model = mujoco.MjModel.from_xml_string(xml_string)
# Find the mesh geom.
for geom_idx in range(model.ngeom):
if model.geom_type[geom_idx] == mujoco.mjtGeom.mjGEOM_MESH:
mesh = mujoco_mesh_to_trimesh(model, geom_idx)
assert mesh is not None, "Failed to convert textured mesh"
assert mesh.visual is not None, "Mesh has no visual"
# Check if texture was extracted (procedural textures should work).
matid = model.geom_matid[geom_idx]
if matid >= 0 and matid < model.nmat:
texid = model.mat_texid[matid]
# texid might be an array, get the first element if so.
if hasattr(texid, "__len__"):
texid = texid[0] if len(texid) > 0 else -1
if texid >= 0:
# Should have extracted the checker texture.
print("✓ Texture extraction: Successfully extracted procedural texture")
return
print("✓ Texture extraction: Tested (no textured meshes in simple model)")
def test_material_colors():
"""Test that material colors are properly applied."""
xml_string = """
"""
model = mujoco.MjModel.from_xml_string(xml_string)
colors_found = []
for geom_idx in range(model.ngeom):
if model.geom_type[geom_idx] == mujoco.mjtGeom.mjGEOM_MESH:
mesh = mujoco_mesh_to_trimesh(model, geom_idx)
# Check visual colors.
visual = mesh.visual
if visual and hasattr(visual, "vertex_colors"):
# Get the first vertex color (they should all be the same).
color = visual.vertex_colors[0]
colors_found.append(tuple(color))
assert len(colors_found) == 3, "Should have 3 meshes"
# Check that we got different colors.
assert colors_found[0][:3] == (255, 0, 0), "First mesh should be red"
assert colors_found[1][:3] == (0, 255, 0), "Second mesh should be green"
# Third mesh should have default color.
print("✓ Material colors: Correctly applied to meshes")
def test_performance():
"""Test conversion performance with a complex model."""
import time
model = load_robot_model("unitree_g1")
mesh_geoms = []
for geom_idx in range(model.ngeom):
if model.geom_type[geom_idx] == mujoco.mjtGeom.mjGEOM_MESH:
mesh_geoms.append(geom_idx)
start_time = time.time()
for geom_idx in mesh_geoms:
mujoco_mesh_to_trimesh(model, geom_idx)
elapsed = time.time() - start_time
avg_time = elapsed / len(mesh_geoms) * 1000 # Convert to ms.
print(f"✓ Performance: Converted {len(mesh_geoms)} meshes in {elapsed:.3f}s")
print(f" - Average: {avg_time:.2f}ms per mesh")
# Warn if it's too slow.
if avg_time > 50:
print(" ⚠ Warning: Conversion is slow (>50ms per mesh)")
def test_mesh_with_texture_coordinates():
"""Test meshes with texture coordinates (Issue #225)."""
xml_string = """
"""
model = mujoco.MjModel.from_xml_string(xml_string)
# Verify the model has texture coordinates.
assert model.mesh_texcoord.shape == (4, 2), "Model should have 4 texture coordinates"
assert model.mesh_texcoordnum[0] == 4, "Mesh should have 4 texture coordinates"
# Convert the mesh.
mesh = mujoco_mesh_to_trimesh(model, 0)
# Verify the mesh was created successfully.
assert mesh is not None, "Failed to convert textured mesh"
assert len(mesh.vertices) > 0, "Mesh has no vertices"
assert len(mesh.faces) > 0, "Mesh has no faces"
# With texture coordinates, vertices are duplicated per face.
# 4 faces * 3 vertices per face = 12 vertices.
assert len(mesh.vertices) == 12, (
f"Expected 12 duplicated vertices, got {len(mesh.vertices)}"
)
assert len(mesh.faces) == 4, f"Expected 4 faces, got {len(mesh.faces)}"
print(
"✓ Mesh with texture coordinates: Successfully converted mesh with texcoords (Issue #225 fixed)"
)
def _make_mixed_visual_model() -> mujoco.MjModel:
"""Create a model with both textured and color-only geoms on the same body."""
xml = """
"""
return mujoco.MjModel.from_xml_string(xml)
def test_get_geom_texture_id():
"""Verify correct texture ID detection for textured vs untextured geoms."""
model = _make_mixed_visual_model()
# Find geom indices by name.
tex_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "tex_geom")
color_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "color_geom")
tex_id = get_geom_texture_id(model, tex_geom_id)
assert tex_id >= 0, "Textured geom should have a non-negative texture ID"
color_id = get_geom_texture_id(model, color_geom_id)
assert color_id == -1, "Color-only geom should have texture ID -1"
def test_get_geom_texture_id_primitive():
"""Primitive (non-mesh) geoms should always return -1."""
xml = """
"""
model = mujoco.MjModel.from_xml_string(xml)
geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "sphere")
assert get_geom_texture_id(model, geom_id) == -1
def test_group_geoms_by_visual_compat():
"""Verify geoms are correctly partitioned by visual compatibility."""
model = _make_mixed_visual_model()
tex_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "tex_geom")
color_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "color_geom")
groups = group_geoms_by_visual_compat(model, [tex_geom_id, color_geom_id])
# Should produce two groups: one textured, one untextured.
assert len(groups) == 2, f"Expected 2 groups, got {len(groups)}"
# Each group should have exactly one geom.
assert all(len(g) == 1 for g in groups)
# Flatten to a set and verify all geoms are present.
all_ids = {gid for g in groups for gid in g}
assert all_ids == {tex_geom_id, color_geom_id}
def test_group_geoms_single_type():
"""All untextured geoms should land in a single group."""
xml = """
"""
model = mujoco.MjModel.from_xml_string(xml)
g1 = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "g1")
g2 = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "g2")
groups = group_geoms_by_visual_compat(model, [g1, g2])
assert len(groups) == 1, "Two untextured geoms should form a single group"
assert set(groups[0]) == {g1, g2}
def test_merge_mixed_visual_geoms():
"""Regression: merging each visual-compat subgroup must preserve visuals."""
import trimesh.visual
model = _make_mixed_visual_model()
tex_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "tex_geom")
color_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "color_geom")
groups = group_geoms_by_visual_compat(model, [tex_geom_id, color_geom_id])
for group in groups:
mesh = merge_geoms(model, group)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
tex_id = get_geom_texture_id(model, group[0])
if tex_id >= 0:
# Textured group should have TextureVisuals, not default gray.
assert isinstance(mesh.visual, trimesh.visual.TextureVisuals), (
f"Expected TextureVisuals for textured group, got {type(mesh.visual)}"
)
else:
# Color group should have ColorVisuals.
assert isinstance(mesh.visual, trimesh.visual.ColorVisuals), (
f"Expected ColorVisuals for color group, got {type(mesh.visual)}"
)
# Verify the color is green (not default gray).
vertex_colors = mesh.visual.vertex_colors
assert vertex_colors[0][1] == 255, (
f"Expected green channel 255, got {vertex_colors[0][1]}"
)
def _make_site_model() -> mujoco.MjModel:
"""Create a model with all 5 site types."""
xml = """
"""
return mujoco.MjModel.from_xml_string(xml)
def test_create_site_mesh_sphere():
"""Test sphere site mesh creation."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "sphere_site")
mesh = create_site_mesh(model, site_id)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
# Check color is red.
assert mesh.visual is not None
assert mesh.visual.vertex_colors[0][0] == 255
assert mesh.visual.vertex_colors[0][1] == 0
def test_create_site_mesh_capsule():
"""Test capsule site mesh creation."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "capsule_site")
mesh = create_site_mesh(model, site_id)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
# Check color is green.
assert mesh.visual is not None
assert mesh.visual.vertex_colors[0][1] == 255
def test_create_site_mesh_ellipsoid():
"""Test ellipsoid site mesh creation."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "ellipsoid_site")
mesh = create_site_mesh(model, site_id)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
# Check color is blue.
assert mesh.visual is not None
assert mesh.visual.vertex_colors[0][2] == 255
def test_create_site_mesh_cylinder():
"""Test cylinder site mesh creation."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "cylinder_site")
mesh = create_site_mesh(model, site_id)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
def test_create_site_mesh_box():
"""Test box site mesh creation."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "box_site")
mesh = create_site_mesh(model, site_id)
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
def test_create_site_mesh_rgba_fallback():
"""Test that all-zero RGBA falls back to gray."""
xml = """
"""
model = mujoco.MjModel.from_xml_string(xml)
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "zero_rgba")
mesh = create_site_mesh(model, site_id)
# Should be gray (0.5 * 255 = 127), not transparent black.
assert mesh.visual is not None
color = mesh.visual.vertex_colors[0]
assert color[0] == 127, f"Expected R=127, got {color[0]}"
assert color[1] == 127, f"Expected G=127, got {color[1]}"
assert color[2] == 127, f"Expected B=127, got {color[2]}"
assert color[3] == 255, f"Expected A=255, got {color[3]}"
def test_merge_sites_single():
"""Test merging a single site returns the site mesh."""
model = _make_site_model()
site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "sphere_site")
mesh = merge_sites(model, [site_id])
assert len(mesh.vertices) > 0
assert len(mesh.faces) > 0
def test_merge_sites_multiple():
"""Test merging multiple sites combines them correctly."""
model = _make_site_model()
sphere_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "sphere_site")
capsule_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "capsule_site")
box_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "box_site")
single_sphere = create_site_mesh(model, sphere_id)
single_capsule = create_site_mesh(model, capsule_id)
single_box = create_site_mesh(model, box_id)
merged = merge_sites(model, [sphere_id, capsule_id, box_id])
# Merged mesh should have at least the sum of individual vertices.
expected_min_verts = (
len(single_sphere.vertices)
+ len(single_capsule.vertices)
+ len(single_box.vertices)
)
assert len(merged.vertices) >= expected_min_verts, (
f"Expected >= {expected_min_verts} vertices, got {len(merged.vertices)}"
)
assert len(merged.faces) > 0
if __name__ == "__main__":
# Run all tests
print("=" * 60)
print("Testing Viser Conversions")
print("=" * 60)
tests = [
test_unitree_g1_conversion,
test_unitree_go1_conversion,
test_texture_extraction,
test_material_colors,
test_performance,
test_mesh_with_texture_coordinates,
test_get_geom_texture_id,
test_get_geom_texture_id_primitive,
test_group_geoms_by_visual_compat,
test_group_geoms_single_type,
test_merge_mixed_visual_geoms,
]
failed = []
for test in tests:
try:
test()
except Exception as e:
test_name = test.__name__ if hasattr(test, "__name__") else str(test)
print(f"✗ {test_name}: {e}")
import traceback
traceback.print_exc()
failed.append(test_name)
print("\n" + "=" * 60)
if failed:
print(f"Failed tests: {', '.join(failed)}")
import sys
sys.exit(1)
else:
print("All tests passed!")
import sys
sys.exit(0)