Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Upstream: https://github.com/michaelgillett/mjlab Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047 Upstream-Branch: main
1773 lines
54 KiB
Python
1773 lines
54 KiB
Python
"""Tests for per-world mesh variant support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, cast
|
|
|
|
import mujoco
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from mjlab.entity import (
|
|
EntityCfg,
|
|
VariantEntityCfg,
|
|
)
|
|
from mjlab.entity.variants import (
|
|
SlotKey,
|
|
VariantGeomSpec,
|
|
VariantSlot,
|
|
allocate_worlds,
|
|
build_variant_model,
|
|
)
|
|
from mjlab.viewer.model_sync import (
|
|
disable_model_sameframe_shortcuts,
|
|
sync_model_fields,
|
|
)
|
|
|
|
# Helpers: variant specs with visual + collision mesh geoms.
|
|
|
|
|
|
def _sphere_2col_spec() -> mujoco.MjSpec:
|
|
"""Sphere: 1 visual + 2 collision geoms."""
|
|
spec = mujoco.MjSpec()
|
|
mv = spec.add_mesh()
|
|
mv.name = "visual"
|
|
mv.make_sphere(subdivision=3)
|
|
for i in range(2):
|
|
mc = spec.add_mesh()
|
|
mc.name = f"col_{i}"
|
|
mc.make_sphere(subdivision=1)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
gv = body.add_geom()
|
|
gv.name = "visual"
|
|
gv.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
gv.meshname = "visual"
|
|
gv.contype = 0
|
|
gv.conaffinity = 0
|
|
for i in range(2):
|
|
gc = body.add_geom()
|
|
gc.name = f"col_{i}"
|
|
gc.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
gc.meshname = f"col_{i}"
|
|
return spec
|
|
|
|
|
|
def _cone_4col_spec() -> mujoco.MjSpec:
|
|
"""Cone: 1 visual + 4 collision geoms (more than sphere)."""
|
|
spec = mujoco.MjSpec()
|
|
mv = spec.add_mesh()
|
|
mv.name = "visual"
|
|
mv.make_cone(nedge=8, radius=0.05)
|
|
for i in range(4):
|
|
mc = spec.add_mesh()
|
|
mc.name = f"col_{i}"
|
|
mc.make_sphere(subdivision=1)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
gv = body.add_geom()
|
|
gv.name = "visual"
|
|
gv.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
gv.meshname = "visual"
|
|
gv.contype = 0
|
|
gv.conaffinity = 0
|
|
for i in range(4):
|
|
gc = body.add_geom()
|
|
gc.name = f"col_{i}"
|
|
gc.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
gc.meshname = f"col_{i}"
|
|
return spec
|
|
|
|
|
|
def _simple_sphere_spec() -> mujoco.MjSpec:
|
|
"""Single-geom sphere for simple tests."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "sphere"
|
|
m.make_sphere(subdivision=2)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "sphere"
|
|
return spec
|
|
|
|
|
|
def _simple_cone_spec() -> mujoco.MjSpec:
|
|
"""Single-geom cone for simple tests."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "cone"
|
|
m.make_cone(nedge=8, radius=0.05)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "cone"
|
|
return spec
|
|
|
|
|
|
def _hinge_spec() -> mujoco.MjSpec:
|
|
"""Object with a hinge joint (incompatible with freejoint variants)."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "box"
|
|
m.make_sphere(subdivision=1)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
j = body.add_joint()
|
|
j.name = "hinge"
|
|
j.type = mujoco.mjtJoint.mjJNT_HINGE
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "box"
|
|
return spec
|
|
|
|
|
|
def _build_scene_with_variants(
|
|
variant_a_fn, variant_b_fn, *, weight_a=0.5, weight_b=0.5
|
|
):
|
|
"""Build a scene spec + variant_info from two variant spec_fns."""
|
|
cfg = VariantEntityCfg(
|
|
variants={"a": variant_a_fn, "b": variant_b_fn},
|
|
assignment={"a": weight_a, "b": weight_b},
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
scene_spec = mujoco.MjSpec()
|
|
frame = scene_spec.worldbody.add_frame()
|
|
scene_spec.attach(entity.spec, prefix="object/", frame=frame)
|
|
return scene_spec, [("object/", entity.variant_metadata)]
|
|
|
|
|
|
# allocate_worlds.
|
|
|
|
|
|
def test_allocate_worlds_proportional():
|
|
result = allocate_worlds((0.6, 0.4), 10)
|
|
assert len(result) == 10
|
|
assert result.count(0) == 6
|
|
assert result.count(1) == 4
|
|
|
|
|
|
def test_allocate_worlds_uniform():
|
|
result = allocate_worlds((1.0, 1.0), 8)
|
|
assert result.count(0) == 4
|
|
assert result.count(1) == 4
|
|
|
|
|
|
def test_allocate_worlds_single_variant():
|
|
result = allocate_worlds((1.0,), 5)
|
|
assert result == [0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_allocate_worlds_zero_weight_skips_variant():
|
|
"""A zero-weight variant gets zero worlds; the rest split nworld."""
|
|
result = allocate_worlds((1.0, 0.0, 1.0), 10)
|
|
assert len(result) == 10
|
|
assert result.count(1) == 0
|
|
assert result.count(0) == 5
|
|
assert result.count(2) == 5
|
|
|
|
|
|
def test_allocate_worlds_rejects_negative_weight():
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
allocate_worlds((1.0, -0.1), 10)
|
|
|
|
|
|
def test_allocate_worlds_rejects_all_zero():
|
|
with pytest.raises(ValueError, match="positive sum"):
|
|
allocate_worlds((0.0, 0.0), 10)
|
|
|
|
|
|
def test_allocate_worlds_largest_remainder_sums_to_nworld():
|
|
"""Largest-remainder rounding must always allocate exactly nworld worlds."""
|
|
for nworld in (3, 7, 100, 1000):
|
|
result = allocate_worlds((1.0, 1.0, 1.0), nworld)
|
|
assert len(result) == nworld
|
|
# Difference between any two variant counts is at most 1 (uniform).
|
|
counts = [result.count(i) for i in range(3)]
|
|
assert max(counts) - min(counts) <= 1
|
|
|
|
|
|
# assignment_fn override.
|
|
|
|
|
|
def test_assignment_fn_overrides_weights():
|
|
"""When assignment_fn is set, it dictates the per-world variant indices."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec, # ignored
|
|
},
|
|
assignment=lambda nworld: [0, 1, 0, 1] * (nworld // 4),
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
scene_spec = mujoco.MjSpec()
|
|
frame = scene_spec.worldbody.add_frame()
|
|
scene_spec.attach(entity.spec, prefix="object/", frame=frame)
|
|
result = build_variant_model(scene_spec, 8, [("object/", entity.variant_metadata)])
|
|
w2v = result.world_to_variant["object/"]
|
|
assert list(w2v) == [0, 1, 0, 1, 0, 1, 0, 1]
|
|
|
|
|
|
def test_assignment_fn_seeded_is_nworld_invariant():
|
|
"""Per-world independent RNG draws make world W's variant a function of W
|
|
alone, independent of nworld."""
|
|
weights = (1.0, 2.0, 1.0)
|
|
cum = np.cumsum(np.asarray(weights) / sum(weights))
|
|
|
|
def seeded_assignment(seed: int):
|
|
def fn(nworld: int) -> list[int]:
|
|
return [
|
|
int(np.searchsorted(cum, np.random.default_rng((seed, w)).random()))
|
|
for w in range(nworld)
|
|
]
|
|
|
|
return fn
|
|
|
|
fn_64 = seeded_assignment(seed=42)(64)
|
|
fn_256 = seeded_assignment(seed=42)(256)
|
|
# World 0..63 must agree across both batch sizes.
|
|
assert fn_64 == fn_256[:64]
|
|
|
|
|
|
def test_assignment_fn_rejects_wrong_length():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
assignment=lambda nworld: [0] * (nworld - 1), # one short
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
scene_spec = mujoco.MjSpec()
|
|
frame = scene_spec.worldbody.add_frame()
|
|
scene_spec.attach(entity.spec, prefix="object/", frame=frame)
|
|
with pytest.raises(ValueError, match="returned .* indices but nworld="):
|
|
build_variant_model(scene_spec, 4, [("object/", entity.variant_metadata)])
|
|
|
|
|
|
def test_assignment_fn_rejects_out_of_range_index():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
assignment=lambda nworld: [0, 1, 0, 99], # 99 is out of range
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
scene_spec = mujoco.MjSpec()
|
|
frame = scene_spec.worldbody.add_frame()
|
|
scene_spec.attach(entity.spec, prefix="object/", frame=frame)
|
|
with pytest.raises(ValueError, match="returned variant index 99"):
|
|
build_variant_model(scene_spec, 4, [("object/", entity.variant_metadata)])
|
|
|
|
|
|
# Entity merging.
|
|
|
|
|
|
def test_entity_builds_with_variants():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
)
|
|
entity = cfg.build()
|
|
meta = entity.variant_metadata
|
|
assert meta is not None
|
|
assert meta.variant_names == ("sphere", "cone")
|
|
assert meta.num_mesh_geoms == 1
|
|
mesh_names = [m.name for m in entity.spec.meshes]
|
|
assert any("sphere" in n for n in mesh_names)
|
|
assert any("cone" in n for n in mesh_names)
|
|
|
|
|
|
def test_multi_geom_body_padding():
|
|
"""Sphere (3 geoms) + cone (5 geoms) -> body padded to 5 mesh geoms."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
},
|
|
)
|
|
entity = cfg.build()
|
|
meta = entity.variant_metadata
|
|
assert meta is not None
|
|
assert meta.num_mesh_geoms == 5 # max(3, 5)
|
|
# Sphere: 3 real + 2 padding (None).
|
|
assert sum(1 for n in meta.variant_mesh_names[0] if n is None) == 2
|
|
# Cone: 5 real, no padding.
|
|
assert all(n is not None for n in meta.variant_mesh_names[1])
|
|
|
|
|
|
# Validation.
|
|
|
|
|
|
def test_mismatched_joint_structure_raises():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"hinge": _hinge_spec,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="joint"):
|
|
cfg.build()
|
|
|
|
|
|
def test_single_variant_builds():
|
|
"""A single variant degenerates cleanly; useful for templated variant sets."""
|
|
cfg = VariantEntityCfg(
|
|
variants={"only": _simple_sphere_spec},
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
assert entity.variant_metadata.variant_names == ("only",)
|
|
|
|
|
|
def test_empty_variants_raises():
|
|
cfg = VariantEntityCfg(variants={})
|
|
with pytest.raises(ValueError, match="at least one"):
|
|
cfg.build()
|
|
|
|
|
|
def _fixed_base_sphere_spec() -> mujoco.MjSpec:
|
|
"""Fixed-base sphere variant (no free joint): currently unsupported."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh(name="sphere")
|
|
m.make_sphere(subdivision=2)
|
|
body = spec.worldbody.add_body(name="prop")
|
|
body.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere")
|
|
return spec
|
|
|
|
|
|
def test_fixed_base_variants_rejected():
|
|
"""Variants must be floating-base; fixed-base raises with a clear message."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"a": _fixed_base_sphere_spec,
|
|
"b": _fixed_base_sphere_spec,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="floating-base"):
|
|
cfg.build()
|
|
|
|
|
|
def test_setting_spec_fn_on_variant_cfg_raises():
|
|
"""VariantEntityCfg.spec_fn is unused; setting it should fail loudly."""
|
|
with pytest.raises(ValueError, match="spec_fn cannot be set"):
|
|
VariantEntityCfg(
|
|
variants={"only": _simple_sphere_spec},
|
|
spec_fn=_simple_sphere_spec,
|
|
)
|
|
|
|
|
|
# Recursive validation: helpers and tests.
|
|
|
|
|
|
def _articulated_spec(
|
|
*,
|
|
root_mesh: str = "root_mesh",
|
|
child_mesh: str = "child_mesh",
|
|
with_grandchild: bool = False,
|
|
) -> mujoco.MjSpec:
|
|
"""Root + child body (hinge joint). Optional grandchild for arity tests."""
|
|
spec = mujoco.MjSpec()
|
|
rm = spec.add_mesh(name=root_mesh)
|
|
rm.make_sphere(subdivision=2)
|
|
cm = spec.add_mesh(name=child_mesh)
|
|
cm.make_sphere(subdivision=2)
|
|
root = spec.worldbody.add_body(name="prop")
|
|
root.add_freejoint()
|
|
rg = root.add_geom()
|
|
rg.name = "root_geom"
|
|
rg.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
rg.meshname = root_mesh
|
|
child = root.add_body(name="lid")
|
|
cj = child.add_joint()
|
|
cj.name = "hinge"
|
|
cj.type = mujoco.mjtJoint.mjJNT_HINGE
|
|
cg = child.add_geom()
|
|
cg.name = "child_geom"
|
|
cg.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
cg.meshname = child_mesh
|
|
if with_grandchild:
|
|
gm = spec.add_mesh(name="grand_mesh")
|
|
gm.make_sphere(subdivision=2)
|
|
grand = child.add_body(name="grand")
|
|
grand.add_geom(
|
|
name="grand_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="grand_mesh"
|
|
)
|
|
return spec
|
|
|
|
|
|
def _spec_with_actuator(actuator_name: str = "act") -> mujoco.MjSpec:
|
|
"""Single-body sphere with a hinge child + a position actuator."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh(name="sphere")
|
|
m.make_sphere(subdivision=2)
|
|
root = spec.worldbody.add_body(name="prop")
|
|
root.add_freejoint()
|
|
root.add_geom(name="visual", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere")
|
|
child = root.add_body(name="lid")
|
|
cj = child.add_joint()
|
|
cj.name = "hinge"
|
|
cj.type = mujoco.mjtJoint.mjJNT_HINGE
|
|
child.add_geom(name="lid_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere")
|
|
act = spec.add_actuator()
|
|
act.name = actuator_name
|
|
act.set_to_motor()
|
|
act.target = "hinge"
|
|
return spec
|
|
|
|
|
|
def _spec_with_primitive(primitive_role: str = "collision") -> mujoco.MjSpec:
|
|
"""Sphere variant with an additional primitive box (visual or collision)."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh(name="sphere")
|
|
m.make_sphere(subdivision=2)
|
|
body = spec.worldbody.add_body(name="prop")
|
|
body.add_freejoint()
|
|
box = body.add_geom()
|
|
box.name = "primitive"
|
|
box.type = mujoco.mjtGeom.mjGEOM_BOX
|
|
box.size = np.array([0.05, 0.05, 0.05])
|
|
if primitive_role == "visual":
|
|
box.contype = 0
|
|
box.conaffinity = 0
|
|
body.add_geom(name="mesh_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere")
|
|
return spec
|
|
|
|
|
|
def _spec_with_diagonal_inertia() -> mujoco.MjSpec:
|
|
spec = _simple_sphere_spec()
|
|
body = list(spec.worldbody.bodies)[0]
|
|
body.explicitinertial = True
|
|
body.mass = 1.0
|
|
body.ipos = np.array([0.0, 0.0, 0.0])
|
|
body.inertia = np.array([0.001, 0.001, 0.001])
|
|
body.iquat = np.array([1.0, 0.0, 0.0, 0.0])
|
|
return spec
|
|
|
|
|
|
def _spec_with_fullinertia() -> mujoco.MjSpec:
|
|
spec = _simple_sphere_spec()
|
|
body = list(spec.worldbody.bodies)[0]
|
|
body.explicitinertial = True
|
|
body.mass = 1.0
|
|
body.ipos = np.array([0.0, 0.0, 0.0])
|
|
body.fullinertia = np.array([0.001, 0.001, 0.001, 0.0, 0.0, 0.0])
|
|
return spec
|
|
|
|
|
|
def _spec_with_reserved_mesh_name() -> mujoco.MjSpec:
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh(name="mjlab/pad/sneaky")
|
|
m.make_sphere(subdivision=2)
|
|
body = spec.worldbody.add_body(name="prop")
|
|
body.add_freejoint()
|
|
body.add_geom(
|
|
name="visual", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="mjlab/pad/sneaky"
|
|
)
|
|
return spec
|
|
|
|
|
|
def test_articulated_same_topology_validates():
|
|
"""Articulated variants with matching topology pass validation
|
|
(build still rejects via floating-base check; this verifies validation
|
|
itself does not complain)."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"a": lambda: _articulated_spec(root_mesh="r_a", child_mesh="c_a"),
|
|
"b": lambda: _articulated_spec(root_mesh="r_b", child_mesh="c_b"),
|
|
},
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
|
|
|
|
def test_recursive_child_body_count_mismatch_rejected():
|
|
"""Variants with different grandchild counts fail recursive validation."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"shallow": lambda: _articulated_spec(with_grandchild=False),
|
|
"deep": lambda: _articulated_spec(with_grandchild=True),
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="child bodies"):
|
|
cfg.build()
|
|
|
|
|
|
def test_recursive_child_body_name_mismatch_rejected():
|
|
def variant_lid():
|
|
return _articulated_spec()
|
|
|
|
def variant_renamed_child():
|
|
spec = _articulated_spec()
|
|
list(spec.worldbody.bodies)[0].bodies[0].name = "drawer_top"
|
|
return spec
|
|
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"lid": variant_lid,
|
|
"drawer": variant_renamed_child,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="body path"):
|
|
cfg.build()
|
|
|
|
|
|
def test_recursive_joint_mismatch_in_child_body_rejected():
|
|
def variant_a():
|
|
return _articulated_spec()
|
|
|
|
def variant_b_slide():
|
|
spec = _articulated_spec()
|
|
child = list(spec.worldbody.bodies)[0].bodies[0]
|
|
list(child.joints)[0].type = mujoco.mjtJoint.mjJNT_SLIDE
|
|
return spec
|
|
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"hinge": variant_a,
|
|
"slide": variant_b_slide,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="joint"):
|
|
cfg.build()
|
|
|
|
|
|
def test_primitive_geom_count_mismatch_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"with_box": _spec_with_primitive,
|
|
"without_box": _simple_sphere_spec,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="non-mesh geoms"):
|
|
cfg.build()
|
|
|
|
|
|
def test_primitive_geom_role_mismatch_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"col": lambda: _spec_with_primitive("collision"),
|
|
"vis": lambda: _spec_with_primitive("visual"),
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="primitive geom"):
|
|
cfg.build()
|
|
|
|
|
|
def test_actuator_count_mismatch_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"no_act": _articulated_spec,
|
|
"with_act": _spec_with_actuator,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="actuator count"):
|
|
cfg.build()
|
|
|
|
|
|
def test_actuator_name_mismatch_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"act_a": lambda: _spec_with_actuator("motor_a"),
|
|
"act_b": lambda: _spec_with_actuator("motor_b"),
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="actuator #0"):
|
|
cfg.build()
|
|
|
|
|
|
def test_fullinertia_diagonal_mixing_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"diag": _spec_with_diagonal_inertia,
|
|
"full": _spec_with_fullinertia,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="inertial representation"):
|
|
cfg.build()
|
|
|
|
|
|
def test_diagonal_inertia_consistent_accepted():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"a": _spec_with_diagonal_inertia,
|
|
"b": _spec_with_diagonal_inertia,
|
|
},
|
|
)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
|
|
|
|
def test_reserved_prefix_in_mesh_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"good": _simple_sphere_spec,
|
|
"bad": _spec_with_reserved_mesh_name,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="reserved name prefix"):
|
|
cfg.build()
|
|
|
|
|
|
def test_validation_error_format():
|
|
"""Error messages use the standardized mjlab.entity prefix and Hint suffix."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"ok": _simple_sphere_spec,
|
|
"bad": _hinge_spec,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError) as excinfo:
|
|
cfg.build()
|
|
msg = str(excinfo.value)
|
|
assert msg.startswith("mjlab.entity: VariantEntityCfg 'bad': ")
|
|
assert "Hint:" in msg
|
|
|
|
|
|
def _spec_with_sensor() -> mujoco.MjSpec:
|
|
"""Sphere with a free joint and a velocity sensor on it."""
|
|
spec = _simple_sphere_spec()
|
|
s = spec.add_sensor()
|
|
s.name = "vel"
|
|
s.type = mujoco.mjtSensor.mjSENS_VELOCIMETER
|
|
s.objtype = mujoco.mjtObj.mjOBJ_SITE
|
|
s.objname = "site_a"
|
|
# Sensor needs a site target; add one.
|
|
body = list(spec.worldbody.bodies)[0]
|
|
site = body.add_site()
|
|
site.name = "site_a"
|
|
return spec
|
|
|
|
|
|
def test_sensor_count_mismatch_rejected():
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"no_sens": _simple_sphere_spec,
|
|
"with_sens": _spec_with_sensor,
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="sensor count"):
|
|
cfg.build()
|
|
|
|
|
|
def test_validate_specs_directly_rejects_zero_root_bodies():
|
|
"""Empty worldbody fails with a clear root-body message."""
|
|
from mjlab.entity.variants import validate_variant_specs
|
|
|
|
empty = mujoco.MjSpec()
|
|
ok = _simple_sphere_spec()
|
|
with pytest.raises(ValueError, match="exactly one root body"):
|
|
validate_variant_specs(["empty", "ok"], [empty, ok])
|
|
|
|
|
|
# Slot metadata (Workstream 3).
|
|
|
|
|
|
def _slot_is_padding(meta, slot_index: int) -> bool:
|
|
"""True if any variant leaves the slot at ``slot_index`` unfilled."""
|
|
return any(specs[slot_index] is None for specs in meta.variant_slot_specs)
|
|
|
|
|
|
def test_slot_metadata_single_variant_single_geom():
|
|
cfg = VariantEntityCfg(variants={"only": _simple_sphere_spec})
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
assert len(meta.slots) == 1
|
|
slot = meta.slots[0]
|
|
assert isinstance(slot, VariantSlot)
|
|
assert slot.key == SlotKey(body_path="/prop", role="collision", ordinal=0)
|
|
assert _slot_is_padding(meta, 0) is False
|
|
assert slot.template_geom_name == "mjlab/pad/prop/collision/0"
|
|
# source_geom_names has one entry per variant.
|
|
assert slot.source_geom_names == ("visual",)
|
|
# variant_slot_specs aligns with slots.
|
|
assert len(meta.variant_slot_specs) == 1
|
|
assert len(meta.variant_slot_specs[0]) == 1
|
|
vgs = meta.variant_slot_specs[0][0]
|
|
assert isinstance(vgs, VariantGeomSpec)
|
|
assert vgs.geom_name == "visual"
|
|
assert vgs.mesh_name == "sphere"
|
|
|
|
|
|
def test_slot_metadata_visual_collision_split():
|
|
"""Visual and collision geoms on the same body get distinct slots."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
# Body /prop has 1 visual + max(2, 4) = 4 collision slots.
|
|
visual_slots = [s for s in meta.slots if s.key.role == "visual"]
|
|
collision_slots = [s for s in meta.slots if s.key.role == "collision"]
|
|
assert len(visual_slots) == 1
|
|
assert len(collision_slots) == 4
|
|
assert all(s.key.body_path == "/prop" for s in meta.slots)
|
|
|
|
|
|
def test_slot_metadata_visual_before_collision():
|
|
"""Slot ordering: visual slots come before collision slots per body."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
roles = [s.key.role for s in meta.slots]
|
|
# Find first collision; all visual should come before it.
|
|
first_col = roles.index("collision")
|
|
assert all(r == "visual" for r in roles[:first_col])
|
|
assert all(r == "collision" for r in roles[first_col:])
|
|
|
|
|
|
def test_slot_metadata_padding_derivable_from_specs():
|
|
"""A slot held by some variants but not others is unfilled (None) for those."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
col_positions = [i for i, s in enumerate(meta.slots) if s.key.role == "collision"]
|
|
# Sphere has 2 collision; cone has 4. Slots 0, 1 fully populated; 2, 3 are padding.
|
|
assert _slot_is_padding(meta, col_positions[0]) is False
|
|
assert _slot_is_padding(meta, col_positions[1]) is False
|
|
assert _slot_is_padding(meta, col_positions[2]) is True
|
|
assert _slot_is_padding(meta, col_positions[3]) is True
|
|
# Sphere has None at the padding slots.
|
|
variant_idx_sphere = meta.variant_names.index("sphere")
|
|
specs_sphere = meta.variant_slot_specs[variant_idx_sphere]
|
|
assert specs_sphere[col_positions[2]] is None
|
|
assert specs_sphere[col_positions[3]] is None
|
|
# Cone has VariantGeomSpec for all collision slots.
|
|
variant_idx_cone = meta.variant_names.index("cone")
|
|
specs_cone = meta.variant_slot_specs[variant_idx_cone]
|
|
for cp in col_positions:
|
|
assert specs_cone[cp] is not None
|
|
|
|
|
|
def test_slot_metadata_articulated_per_body_slots():
|
|
"""Articulated variants produce slots per (body, role)."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"a": lambda: _articulated_spec(root_mesh="ar", child_mesh="ac"),
|
|
"b": lambda: _articulated_spec(root_mesh="br", child_mesh="bc"),
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
# Two bodies (/prop, /prop/lid), each with 1 collision mesh -> 2 slots.
|
|
paths = sorted({s.key.body_path for s in meta.slots})
|
|
assert paths == ["/prop", "/prop/lid"]
|
|
# Each body has exactly one collision slot, no visuals.
|
|
for path in paths:
|
|
body_slots = [s for s in meta.slots if s.key.body_path == path]
|
|
assert len(body_slots) == 1
|
|
assert body_slots[0].key.role == "collision"
|
|
slot_idx = meta.slots.index(body_slots[0])
|
|
assert _slot_is_padding(meta, slot_idx) is False
|
|
# Mesh names captured per variant per slot.
|
|
variant_a_specs = meta.variant_slot_specs[meta.variant_names.index("a")]
|
|
variant_b_specs = meta.variant_slot_specs[meta.variant_names.index("b")]
|
|
a_meshes = sorted(s.mesh_name for s in variant_a_specs if s is not None)
|
|
b_meshes = sorted(s.mesh_name for s in variant_b_specs if s is not None)
|
|
assert a_meshes == ["ac", "ar"]
|
|
assert b_meshes == ["bc", "br"]
|
|
|
|
|
|
def test_slot_metadata_template_name_under_reserved_prefix():
|
|
"""Every template slot name starts with the reserved mjlab/pad/ prefix."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"a": _simple_sphere_spec,
|
|
"b": _simple_cone_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
for slot in meta.slots:
|
|
assert slot.template_geom_name.startswith("mjlab/pad/")
|
|
|
|
|
|
def test_slot_metadata_captures_visual_role_from_zero_contact_bits():
|
|
"""A geom with contype=0 and conaffinity=0 is classified as visual."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
visual_slots = [s for s in meta.slots if s.key.role == "visual"]
|
|
assert len(visual_slots) == 1
|
|
visual_slot = visual_slots[0]
|
|
for variant_specs in meta.variant_slot_specs:
|
|
spec_at_visual = variant_specs[meta.slots.index(visual_slot)]
|
|
assert spec_at_visual is not None
|
|
assert spec_at_visual.contype == 0
|
|
assert spec_at_visual.conaffinity == 0
|
|
|
|
|
|
def test_slot_metadata_ordinals_are_zero_based_per_body_and_role():
|
|
"""Within a (body, role), slot ordinals are 0..max-1."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
collision_slots = [s for s in meta.slots if s.key.role == "collision"]
|
|
assert [s.key.ordinal for s in collision_slots] == [0, 1, 2, 3]
|
|
visual_slots = [s for s in meta.slots if s.key.role == "visual"]
|
|
assert [s.key.ordinal for s in visual_slots] == [0]
|
|
|
|
|
|
def test_slot_metadata_alignment_invariant():
|
|
"""variant_slot_specs[v] aligns with slots positionally for every variant."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
for variant_specs in meta.variant_slot_specs:
|
|
assert len(variant_specs) == len(meta.slots)
|
|
|
|
|
|
def test_template_geom_contype_matches_slot_role():
|
|
"""Template contype/conaffinity are derived from slot role (union of variants)."""
|
|
scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
metadata = vi[0][1]
|
|
for slot in metadata.slots:
|
|
full_name = f"object/{slot.template_geom_name}"
|
|
gid = mujoco.mj_name2id(result.mj_model, mujoco.mjtObj.mjOBJ_GEOM, full_name)
|
|
assert gid >= 0, f"slot geom '{full_name}' missing from compiled model"
|
|
contype = int(result.mj_model.geom_contype[gid])
|
|
conaffinity = int(result.mj_model.geom_conaffinity[gid])
|
|
if slot.key.role == "visual":
|
|
assert contype == 0, f"visual slot {slot.key} has contype={contype}"
|
|
assert conaffinity == 0, f"visual slot {slot.key} has conaffinity={conaffinity}"
|
|
else:
|
|
assert contype == 1, f"collision slot {slot.key} has contype={contype}"
|
|
assert conaffinity == 1, (
|
|
f"collision slot {slot.key} has conaffinity={conaffinity}"
|
|
)
|
|
|
|
|
|
def test_template_geoms_use_mjlab_pad_prefix():
|
|
"""All entity mesh-geom names in the compiled template use mjlab/pad/ prefix."""
|
|
scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
for gid in range(result.mj_model.ngeom):
|
|
if result.mj_model.geom_type[gid] != mujoco.mjtGeom.mjGEOM_MESH:
|
|
continue
|
|
name = mujoco.mj_id2name(result.mj_model, mujoco.mjtObj.mjOBJ_GEOM, gid) or ""
|
|
if not name.startswith("object/"):
|
|
continue
|
|
suffix = name[len("object/") :]
|
|
assert suffix.startswith("mjlab/pad/"), (
|
|
f"entity mesh geom '{name}' does not use mjlab/pad/ prefix"
|
|
)
|
|
|
|
|
|
def test_visual_collision_split_inertia_matches_independent_compile():
|
|
"""Per-world body_mass matches independent compile when variants have visual+collision split.
|
|
|
|
This verifies the slot-driven reference compile preserves the visual
|
|
role (contype=0/conaffinity=0) on the visual mesh; if the old
|
|
contype=1/conaffinity=1 reset still ran, the visual mesh's
|
|
inertia-inference behavior would not change for default groups, but
|
|
this exercise pins the contract end-to-end.
|
|
"""
|
|
scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
sphere_model = _sphere_2col_spec().compile()
|
|
cone_model = _cone_4col_spec().compile()
|
|
|
|
body_mass = result.wp_model.body_mass.numpy()
|
|
w2v = result.world_to_variant["object/"]
|
|
obj_body = result.mj_model.nbody - 1
|
|
|
|
sphere_w = int(np.where(w2v == 0)[0][0])
|
|
cone_w = int(np.where(w2v == 1)[0][0])
|
|
|
|
np.testing.assert_allclose(
|
|
body_mass[sphere_w, obj_body],
|
|
sphere_model.body_mass[-1],
|
|
atol=1e-4,
|
|
)
|
|
np.testing.assert_allclose(
|
|
body_mass[cone_w, obj_body],
|
|
cone_model.body_mass[-1],
|
|
atol=1e-4,
|
|
)
|
|
|
|
|
|
def test_variant_order_irrelevant_per_variant_compile():
|
|
"""Reordering the variant dict does not change per-variant per-world fields."""
|
|
cfg_ab = VariantEntityCfg(
|
|
variants={
|
|
"a": _simple_sphere_spec,
|
|
"b": _simple_cone_spec,
|
|
}
|
|
)
|
|
cfg_ba = VariantEntityCfg(
|
|
variants={
|
|
"b": _simple_cone_spec,
|
|
"a": _simple_sphere_spec,
|
|
}
|
|
)
|
|
|
|
def _build_scene(cfg: VariantEntityCfg):
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is not None
|
|
scene_spec = mujoco.MjSpec()
|
|
frame = scene_spec.worldbody.add_frame()
|
|
scene_spec.attach(entity.spec, prefix="object/", frame=frame)
|
|
return scene_spec, [("object/", entity.variant_metadata)]
|
|
|
|
scene_ab, vi_ab = _build_scene(cfg_ab)
|
|
scene_ba, vi_ba = _build_scene(cfg_ba)
|
|
res_ab = build_variant_model(scene_ab, 4, vi_ab)
|
|
res_ba = build_variant_model(scene_ba, 4, vi_ba)
|
|
|
|
obj_body_ab = res_ab.mj_model.nbody - 1
|
|
obj_body_ba = res_ba.mj_model.nbody - 1
|
|
|
|
body_mass_ab = res_ab.wp_model.body_mass.numpy()
|
|
body_mass_ba = res_ba.wp_model.body_mass.numpy()
|
|
|
|
# In cfg_ab, "a" is variant index 0; in cfg_ba, "a" is variant index 1.
|
|
w2v_ab = res_ab.world_to_variant["object/"]
|
|
w2v_ba = res_ba.world_to_variant["object/"]
|
|
a_world_ab = int(np.where(w2v_ab == 0)[0][0])
|
|
a_world_ba = int(np.where(w2v_ba == 1)[0][0])
|
|
b_world_ab = int(np.where(w2v_ab == 1)[0][0])
|
|
b_world_ba = int(np.where(w2v_ba == 0)[0][0])
|
|
|
|
np.testing.assert_allclose(
|
|
body_mass_ab[a_world_ab, obj_body_ab],
|
|
body_mass_ba[a_world_ba, obj_body_ba],
|
|
atol=1e-5,
|
|
err_msg="variant 'a' body_mass differs across orderings",
|
|
)
|
|
np.testing.assert_allclose(
|
|
body_mass_ab[b_world_ab, obj_body_ab],
|
|
body_mass_ba[b_world_ba, obj_body_ba],
|
|
atol=1e-5,
|
|
err_msg="variant 'b' body_mass differs across orderings",
|
|
)
|
|
|
|
|
|
def test_slot_metadata_source_geom_names_record_padding_as_none():
|
|
"""source_geom_names has None where a variant doesn't fill a slot."""
|
|
cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _sphere_2col_spec,
|
|
"cone": _cone_4col_spec,
|
|
}
|
|
)
|
|
meta = cfg.build().variant_metadata
|
|
assert meta is not None
|
|
sphere_idx = meta.variant_names.index("sphere")
|
|
collision_slots = [s for s in meta.slots if s.key.role == "collision"]
|
|
# Sphere has 2 collision -> ordinals 2, 3 are None for sphere.
|
|
assert collision_slots[2].source_geom_names[sphere_idx] is None
|
|
assert collision_slots[3].source_geom_names[sphere_idx] is None
|
|
# Cone fills all 4.
|
|
cone_idx = meta.variant_names.index("cone")
|
|
for cs in collision_slots:
|
|
assert cs.source_geom_names[cone_idx] is not None
|
|
|
|
|
|
def test_no_variants_unchanged():
|
|
cfg = EntityCfg(spec_fn=_simple_sphere_spec)
|
|
entity = cfg.build()
|
|
assert entity.variant_metadata is None
|
|
|
|
|
|
# build_variant_model: dataid and dependent fields.
|
|
|
|
|
|
def test_dataid_assigned_per_world():
|
|
"""Each world's geom_dataid points to its variant's meshes."""
|
|
scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
dataid = result.wp_model.geom_dataid.numpy()
|
|
assert dataid.shape == (4, result.mj_model.ngeom)
|
|
assert dataid.ndim == 2
|
|
|
|
w2v = result.world_to_variant["object/"]
|
|
assert w2v[0] == 0 # variant a (sphere)
|
|
assert w2v[2] == 1 # variant b (cone)
|
|
|
|
# Sphere and cone worlds must have different dataid values.
|
|
assert not np.array_equal(dataid[0], dataid[2])
|
|
|
|
|
|
def _sphere_with_material_spec() -> mujoco.MjSpec:
|
|
"""Single-geom sphere whose visual references a named material."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "sphere"
|
|
m.make_sphere(subdivision=2)
|
|
mat = spec.add_material()
|
|
mat.name = "red_mat"
|
|
mat.rgba[:] = (1.0, 0.0, 0.0, 1.0)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "sphere"
|
|
g.material = "red_mat"
|
|
return spec
|
|
|
|
|
|
def _cone_with_material_spec() -> mujoco.MjSpec:
|
|
"""Single-geom cone whose visual references a different named material."""
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "cone"
|
|
m.make_cone(nedge=8, radius=0.05)
|
|
mat = spec.add_material()
|
|
mat.name = "blue_mat"
|
|
mat.rgba[:] = (0.0, 0.0, 1.0, 1.0)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "cone"
|
|
g.material = "blue_mat"
|
|
return spec
|
|
|
|
|
|
def test_materials_merged_under_variant_prefix():
|
|
"""Both variants' materials end up in the merged spec, name-prefixed."""
|
|
scene_spec, vi = _build_scene_with_variants(
|
|
_sphere_with_material_spec, _cone_with_material_spec
|
|
)
|
|
model = scene_spec.compile()
|
|
mat_names = {model.material(i).name for i in range(model.nmat)}
|
|
assert "object/a/red_mat" in mat_names
|
|
assert "object/b/blue_mat" in mat_names
|
|
|
|
|
|
def test_matid_assigned_per_world():
|
|
"""Each world's geom_matid points to its variant's material."""
|
|
scene_spec, vi = _build_scene_with_variants(
|
|
_sphere_with_material_spec, _cone_with_material_spec
|
|
)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
matid = result.wp_model.geom_matid.numpy()
|
|
assert matid.shape == (4, result.mj_model.ngeom)
|
|
|
|
w2v = result.world_to_variant["object/"]
|
|
red_id = mujoco.mj_name2id(
|
|
result.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "object/a/red_mat"
|
|
)
|
|
blue_id = mujoco.mj_name2id(
|
|
result.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "object/b/blue_mat"
|
|
)
|
|
assert red_id >= 0 and blue_id >= 0 and red_id != blue_id
|
|
|
|
# Slot geom is the last mesh geom (single-geom variants -> ordinal 0).
|
|
slot_gid = next(
|
|
gid
|
|
for gid in range(result.mj_model.ngeom - 1, -1, -1)
|
|
if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH
|
|
)
|
|
|
|
for w in range(4):
|
|
expected = red_id if w2v[w] == 0 else blue_id
|
|
assert int(matid[w, slot_gid]) == expected
|
|
|
|
|
|
def test_matid_minus_one_when_variant_has_no_material():
|
|
"""A variant slot without a material yields geom_matid == -1 in its worlds."""
|
|
scene_spec, vi = _build_scene_with_variants(
|
|
_sphere_with_material_spec,
|
|
_simple_cone_spec, # cone has no material
|
|
)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
matid = result.wp_model.geom_matid.numpy()
|
|
w2v = result.world_to_variant["object/"]
|
|
slot_gid = next(
|
|
gid
|
|
for gid in range(result.mj_model.ngeom - 1, -1, -1)
|
|
if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH
|
|
)
|
|
cone_world = int(np.where(w2v == 1)[0][0])
|
|
assert int(matid[cone_world, slot_gid]) == -1
|
|
|
|
|
|
def test_padding_slots_get_disabled():
|
|
"""Shorter variant's padding geom slots have dataid == -1."""
|
|
scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
dataid = result.wp_model.geom_dataid.numpy()
|
|
w2v = result.world_to_variant["object/"]
|
|
|
|
# Find a sphere world (variant 0, 3 mesh geoms -> 2 padding slots).
|
|
sphere_world = int(np.where(w2v == 0)[0][0])
|
|
# Find mesh geom columns (skip non-mesh geoms like worldbody).
|
|
mesh_geom_ids = [
|
|
gid
|
|
for gid in range(result.mj_model.ngeom)
|
|
if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH
|
|
]
|
|
sphere_dataid = dataid[sphere_world, mesh_geom_ids]
|
|
# Last 2 mesh geom slots should be -1 (disabled padding).
|
|
assert sphere_dataid[-1] == -1
|
|
assert sphere_dataid[-2] == -1
|
|
# Padding slots must still be collision-enabled in the template/warp model.
|
|
# Short variants are disabled by per-world dataid=-1; long variants need the
|
|
# same slots enabled so their extra hulls can collide.
|
|
assert np.all(result.mj_model.geom_contype[mesh_geom_ids[-2:]] == 1)
|
|
assert np.all(result.mj_model.geom_conaffinity[mesh_geom_ids[-2:]] == 1)
|
|
assert np.all(result.wp_model.geom_contype.numpy()[mesh_geom_ids[-2:]] == 1)
|
|
assert np.all(result.wp_model.geom_conaffinity.numpy()[mesh_geom_ids[-2:]] == 1)
|
|
# First 3 should be valid (>= 0).
|
|
assert all(d >= 0 for d in sphere_dataid[:3])
|
|
|
|
|
|
def test_dependent_fields_match_individual_compilation():
|
|
"""Per-world body_mass matches independently compiled variant models."""
|
|
scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec)
|
|
result = build_variant_model(scene_spec, 4, vi)
|
|
|
|
# Compile each variant independently for reference values.
|
|
sphere_model = _simple_sphere_spec().compile()
|
|
cone_model = _simple_cone_spec().compile()
|
|
|
|
body_mass = result.wp_model.body_mass.numpy()
|
|
w2v = result.world_to_variant["object/"]
|
|
|
|
sphere_w = int(np.where(w2v == 0)[0][0])
|
|
cone_w = int(np.where(w2v == 1)[0][0])
|
|
|
|
# The object body is the last body in the scene.
|
|
obj_body = result.mj_model.nbody - 1
|
|
|
|
# Mass should match individually compiled models.
|
|
np.testing.assert_allclose(
|
|
body_mass[sphere_w, obj_body],
|
|
sphere_model.body_mass[-1],
|
|
atol=1e-4,
|
|
)
|
|
np.testing.assert_allclose(
|
|
body_mass[cone_w, obj_body],
|
|
cone_model.body_mass[-1],
|
|
atol=1e-4,
|
|
)
|
|
|
|
# Sphere and cone should have different masses.
|
|
assert not np.isclose(body_mass[sphere_w, obj_body], body_mass[cone_w, obj_body])
|
|
|
|
|
|
def test_select_default_values_uses_per_world_variant_defaults():
|
|
"""Per-world defaults are indexed by env first, then by entity."""
|
|
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
|
|
from mjlab.envs.mdp.dr._core import _select_default_values
|
|
from mjlab.scene import SceneCfg
|
|
from mjlab.terrains import TerrainEntityCfg
|
|
|
|
def _explicit_variant(
|
|
mesh_name: str,
|
|
mass: float,
|
|
inertia: tuple[float, float, float],
|
|
*,
|
|
cone: bool = False,
|
|
) -> mujoco.MjSpec:
|
|
spec = mujoco.MjSpec()
|
|
mesh = spec.add_mesh()
|
|
mesh.name = mesh_name
|
|
if cone:
|
|
mesh.make_cone(nedge=8, radius=0.05)
|
|
else:
|
|
mesh.make_sphere(subdivision=1)
|
|
body = spec.worldbody.add_body(name="prop")
|
|
body.add_freejoint()
|
|
body.explicitinertial = True
|
|
body.mass = mass
|
|
body.ipos[:] = (0.0, 0.0, 0.0)
|
|
body.inertia[:] = inertia
|
|
body.iquat[:] = (1.0, 0.0, 0.0, 0.0)
|
|
body.add_geom(
|
|
name="visual",
|
|
type=mujoco.mjtGeom.mjGEOM_MESH,
|
|
meshname=mesh_name,
|
|
contype=0,
|
|
conaffinity=0,
|
|
mass=0.0,
|
|
)
|
|
return spec
|
|
|
|
object_cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": lambda: _explicit_variant("sphere", 0.2, (1e-4, 2e-4, 3e-4)),
|
|
"cone": lambda: _explicit_variant("cone", 0.7, (4e-4, 5e-4, 6e-4), cone=True),
|
|
},
|
|
init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)),
|
|
)
|
|
env_cfg = ManagerBasedRlEnvCfg(
|
|
decimation=1,
|
|
scene=SceneCfg(
|
|
terrain=TerrainEntityCfg(terrain_type="plane"),
|
|
num_envs=4,
|
|
env_spacing=1.0,
|
|
entities={"object": object_cfg},
|
|
),
|
|
)
|
|
|
|
env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu")
|
|
try:
|
|
obj_body = int(env.scene["object"].indexing.root_body_id)
|
|
env_ids = torch.arange(env.num_envs, device=env.device)
|
|
body_ids = torch.tensor([obj_body], device=env.device)
|
|
|
|
for field in ("body_mass", "body_ipos", "body_inertia", "body_iquat"):
|
|
selected = _select_default_values(env, field, env_ids, body_ids)
|
|
torch.testing.assert_close(
|
|
selected[:, 0],
|
|
getattr(env.sim.model, field)[:, obj_body],
|
|
)
|
|
finally:
|
|
env.close()
|
|
|
|
|
|
def test_viser_builds_per_world_mesh_handles_for_variants():
|
|
"""Viser dynamic meshes must not collapse all worlds onto env0's mesh."""
|
|
from contextlib import nullcontext
|
|
|
|
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
|
|
from mjlab.scene import SceneCfg
|
|
from mjlab.terrains import TerrainEntityCfg
|
|
from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldMeshGroup
|
|
|
|
class _Handle:
|
|
def __init__(self, **kwargs):
|
|
self.visible = kwargs.get("visible", True)
|
|
self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3)))
|
|
self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4)))
|
|
self.batched_scales = kwargs.get("batched_scales")
|
|
self.batched_colors = kwargs.get("batched_colors")
|
|
self.batched_opacities = kwargs.get("batched_opacities")
|
|
self.position = kwargs.get("position", np.zeros(3))
|
|
self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0]))
|
|
|
|
def remove(self) -> None:
|
|
pass
|
|
|
|
class _Scene:
|
|
def __init__(self):
|
|
self.batched: list[tuple[tuple, dict, _Handle]] = []
|
|
|
|
def configure_environment_map(self, **_kwargs) -> None:
|
|
pass
|
|
|
|
def add_frame(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_grid(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle:
|
|
handle = _Handle(**kwargs)
|
|
self.batched.append((args, kwargs, handle))
|
|
return handle
|
|
|
|
def add_batched_meshes_simple(self, *args, **kwargs) -> _Handle:
|
|
handle = _Handle(**kwargs)
|
|
self.batched.append((args, kwargs, handle))
|
|
return handle
|
|
|
|
class _Server:
|
|
def __init__(self):
|
|
self.scene = _Scene()
|
|
|
|
def atomic(self):
|
|
return nullcontext()
|
|
|
|
def flush(self) -> None:
|
|
pass
|
|
|
|
env_cfg = ManagerBasedRlEnvCfg(
|
|
decimation=1,
|
|
scene=SceneCfg(
|
|
terrain=TerrainEntityCfg(terrain_type="plane"),
|
|
num_envs=4,
|
|
env_spacing=1.0,
|
|
entities={
|
|
"object": VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
|
|
env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu")
|
|
try:
|
|
env.sim.expand_model_fields(("geom_rgba",))
|
|
env.sim.model.geom_rgba[:, :, :3] = torch.linspace(
|
|
0.2,
|
|
0.9,
|
|
env.num_envs,
|
|
device=env.device,
|
|
)[:, None, None]
|
|
server = _Server()
|
|
scene = MjlabViserScene(
|
|
cast(Any, server),
|
|
env.sim.mj_model,
|
|
env.num_envs,
|
|
sim_model=env.sim.model,
|
|
expanded_fields=env.sim.expanded_fields,
|
|
)
|
|
groups = [mg for mg in scene._mesh_groups if isinstance(mg, _PerWorldMeshGroup)]
|
|
|
|
assert groups
|
|
assert sum(len(mg.env_ids) for mg in groups) >= env.num_envs
|
|
|
|
body_xpos = env.sim.data.xpos.cpu().numpy()
|
|
body_xmat = env.sim.data.xmat.cpu().numpy()
|
|
mocap_pos = (
|
|
env.sim.data.mocap_pos.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None
|
|
)
|
|
mocap_quat = (
|
|
env.sim.data.mocap_quat.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None
|
|
)
|
|
scene.show_only_selected = True
|
|
scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0)
|
|
scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=1)
|
|
|
|
assert any(mg.handle.visible for mg in groups)
|
|
|
|
handle_count = len(server.scene.batched)
|
|
env.sim.model.geom_rgba[:, :, :3] = torch.linspace(
|
|
0.9,
|
|
0.2,
|
|
env.num_envs,
|
|
device=env.device,
|
|
)[:, None, None]
|
|
scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0)
|
|
assert len(server.scene.batched) > handle_count
|
|
finally:
|
|
env.close()
|
|
|
|
|
|
def test_viser_convex_hulls_are_per_variant():
|
|
"""Convex-hull handles must differ across variants, not all show env0's hull."""
|
|
from contextlib import nullcontext
|
|
|
|
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
|
|
from mjlab.scene import SceneCfg
|
|
from mjlab.terrains import TerrainEntityCfg
|
|
from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldHullGroup
|
|
|
|
class _Handle:
|
|
def __init__(self, **kwargs):
|
|
self.visible = kwargs.get("visible", True)
|
|
self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3)))
|
|
self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4)))
|
|
self.batched_scales = kwargs.get("batched_scales")
|
|
self.batched_colors = kwargs.get("batched_colors")
|
|
self.batched_opacities = kwargs.get("batched_opacities")
|
|
self.position = kwargs.get("position", np.zeros(3))
|
|
self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0]))
|
|
self.vertices = kwargs.get("vertices")
|
|
self.faces = kwargs.get("faces")
|
|
|
|
def remove(self) -> None:
|
|
pass
|
|
|
|
class _Scene:
|
|
def __init__(self):
|
|
self.batched: list[tuple[tuple, dict, _Handle]] = []
|
|
|
|
def configure_environment_map(self, **_kwargs) -> None:
|
|
pass
|
|
|
|
def add_frame(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_grid(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle:
|
|
return _Handle(**kwargs)
|
|
|
|
def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle:
|
|
handle = _Handle(**kwargs)
|
|
self.batched.append((args, kwargs, handle))
|
|
return handle
|
|
|
|
def add_batched_meshes_simple(self, path, vertices, faces, **kwargs) -> _Handle:
|
|
# Capture the mesh identity so the test can compare hull shapes.
|
|
kwargs = dict(kwargs)
|
|
kwargs["vertices"] = np.asarray(vertices)
|
|
kwargs["faces"] = np.asarray(faces)
|
|
handle = _Handle(**kwargs)
|
|
self.batched.append(((path,), kwargs, handle))
|
|
return handle
|
|
|
|
class _Server:
|
|
def __init__(self):
|
|
self.scene = _Scene()
|
|
|
|
def atomic(self):
|
|
return nullcontext()
|
|
|
|
def flush(self) -> None:
|
|
pass
|
|
|
|
# Sphere and cone produce visibly different convex hulls.
|
|
env_cfg = ManagerBasedRlEnvCfg(
|
|
decimation=1,
|
|
scene=SceneCfg(
|
|
terrain=TerrainEntityCfg(terrain_type="plane"),
|
|
num_envs=4,
|
|
env_spacing=1.0,
|
|
entities={
|
|
"object": VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
|
|
env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu")
|
|
try:
|
|
server = _Server()
|
|
scene = MjlabViserScene(
|
|
cast(Any, server),
|
|
env.sim.mj_model,
|
|
env.num_envs,
|
|
sim_model=env.sim.model,
|
|
expanded_fields=env.sim.expanded_fields,
|
|
)
|
|
groups: list[_PerWorldHullGroup] = list(scene._hull_per_world_groups)
|
|
# Two distinct variants -> at least two hull handles on the same body.
|
|
assert len(groups) >= 2, f"expected >=2 hull variants, got {len(groups)}"
|
|
all_envs = np.concatenate([g.env_ids for g in groups])
|
|
assert sorted(all_envs.tolist()) == list(range(env.num_envs))
|
|
# Hulls must be shape-distinct, not all copies of env0's hull.
|
|
shapes = {(g.handle.vertices.shape, g.handle.faces.shape) for g in groups}
|
|
assert len(shapes) >= 2, (
|
|
f"hull variants collapsed to one shape: {shapes} "
|
|
"(all envs would share env0's hull)"
|
|
)
|
|
|
|
body_xpos = env.sim.data.xpos.cpu().numpy()
|
|
body_xmat = env.sim.data.xmat.cpu().numpy()
|
|
scene.show_convex_hull = True
|
|
scene.show_only_selected = True
|
|
for target_env in range(env.num_envs):
|
|
scene.update_from_arrays(body_xpos, body_xmat, env_idx=target_env)
|
|
visible_groups = [g for g in groups if g.handle.visible]
|
|
assert len(visible_groups) == 1
|
|
assert target_env in visible_groups[0].env_ids
|
|
assert visible_groups[0].handle.batched_positions.shape[0] == 1
|
|
|
|
scene.show_only_selected = False
|
|
scene.update_from_arrays(body_xpos, body_xmat, env_idx=0)
|
|
assert all(g.handle.visible for g in groups)
|
|
finally:
|
|
env.close()
|
|
|
|
|
|
# DR consistency on variant scenes.
|
|
|
|
|
|
def _explicit_mass_variant(
|
|
mesh_name: str,
|
|
mass: float,
|
|
*,
|
|
cone: bool = False,
|
|
) -> mujoco.MjSpec:
|
|
"""Build a single-geom freejoint variant with an explicit body mass."""
|
|
spec = mujoco.MjSpec()
|
|
mesh = spec.add_mesh()
|
|
mesh.name = mesh_name
|
|
if cone:
|
|
mesh.make_cone(nedge=8, radius=0.05)
|
|
else:
|
|
mesh.make_sphere(subdivision=1)
|
|
body = spec.worldbody.add_body(name="prop")
|
|
body.add_freejoint()
|
|
body.explicitinertial = True
|
|
body.mass = mass
|
|
body.ipos[:] = (0.0, 0.0, 0.0)
|
|
body.inertia[:] = (1e-4, 1e-4, 1e-4)
|
|
body.iquat[:] = (1.0, 0.0, 0.0, 0.0)
|
|
body.add_geom(
|
|
name="visual",
|
|
type=mujoco.mjtGeom.mjGEOM_MESH,
|
|
meshname=mesh_name,
|
|
contype=0,
|
|
conaffinity=0,
|
|
mass=0.0,
|
|
)
|
|
return spec
|
|
|
|
|
|
def test_dr_body_mass_scale_preserves_variant_baseline():
|
|
"""``dr.body_mass`` scale must use each variant's own baseline.
|
|
|
|
This is the load-bearing claim of ``_per_world_default_fields``: scaling
|
|
body_mass on a variant scene by a per-env factor must produce
|
|
``variant_default[env] * scale[env]``, not ``template_default * scale[env]``.
|
|
"""
|
|
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
|
|
from mjlab.envs.mdp import dr
|
|
from mjlab.managers.event_manager import EventTermCfg
|
|
from mjlab.managers.scene_entity_config import SceneEntityCfg
|
|
from mjlab.scene import SceneCfg
|
|
from mjlab.terrains import TerrainEntityCfg
|
|
|
|
light_mass = 0.1
|
|
heavy_mass = 1.0
|
|
scale = 2.0
|
|
|
|
object_cfg = VariantEntityCfg(
|
|
variants={
|
|
"light": lambda: _explicit_mass_variant("light", light_mass),
|
|
"heavy": lambda: _explicit_mass_variant("heavy", heavy_mass, cone=True),
|
|
},
|
|
init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)),
|
|
)
|
|
env_cfg = ManagerBasedRlEnvCfg(
|
|
decimation=1,
|
|
scene=SceneCfg(
|
|
terrain=TerrainEntityCfg(terrain_type="plane"),
|
|
num_envs=4,
|
|
env_spacing=1.0,
|
|
entities={"object": object_cfg},
|
|
),
|
|
events={
|
|
"scale_mass": EventTermCfg(
|
|
func=dr.body_mass,
|
|
mode="startup",
|
|
params={
|
|
"asset_cfg": SceneEntityCfg("object", body_names=("prop",)),
|
|
"operation": "scale",
|
|
"ranges": (scale, scale), # deterministic factor
|
|
},
|
|
),
|
|
},
|
|
)
|
|
|
|
with pytest.warns(UserWarning, match="dr.body_mass only randomizes mass"):
|
|
env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu")
|
|
try:
|
|
obj_body = int(env.scene["object"].indexing.root_body_id)
|
|
w2v = env.sim.world_to_variant["object"]
|
|
actual = env.sim.model.body_mass[:, obj_body].cpu()
|
|
|
|
variant_baseline = torch.tensor([light_mass, heavy_mass], dtype=actual.dtype)
|
|
expected = variant_baseline[w2v.cpu()] * scale
|
|
torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5)
|
|
|
|
# Sanity: at least one env per variant, otherwise the test is vacuous.
|
|
assert (w2v == 0).any() and (w2v == 1).any()
|
|
finally:
|
|
env.close()
|
|
|
|
|
|
# Full env lifecycle.
|
|
|
|
|
|
def test_env_step_with_variants():
|
|
"""Build a full ManagerBasedRlEnv with variants; step without crashing."""
|
|
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
|
|
from mjlab.envs.mdp.events import reset_root_state_uniform
|
|
from mjlab.managers.event_manager import EventTermCfg
|
|
from mjlab.managers.scene_entity_config import SceneEntityCfg
|
|
from mjlab.scene import SceneCfg
|
|
from mjlab.terrains import TerrainEntityCfg
|
|
|
|
object_cfg = VariantEntityCfg(
|
|
variants={
|
|
"sphere": _simple_sphere_spec,
|
|
"cone": _simple_cone_spec,
|
|
},
|
|
init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)),
|
|
)
|
|
|
|
env_cfg = ManagerBasedRlEnvCfg(
|
|
decimation=2,
|
|
scene=SceneCfg(
|
|
terrain=TerrainEntityCfg(terrain_type="plane"),
|
|
num_envs=4,
|
|
env_spacing=1.0,
|
|
entities={"object": object_cfg},
|
|
),
|
|
events={
|
|
"reset": EventTermCfg(
|
|
func=reset_root_state_uniform,
|
|
mode="reset",
|
|
params={
|
|
"pose_range": {},
|
|
"velocity_range": {},
|
|
"asset_cfg": SceneEntityCfg("object"),
|
|
},
|
|
),
|
|
},
|
|
)
|
|
|
|
env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu")
|
|
obs, _ = env.reset()
|
|
actions = torch.zeros(env.num_envs, 0)
|
|
for _ in range(10):
|
|
obs, rew, term, trunc, info = env.step(actions)
|
|
# No NaN in positions.
|
|
qpos = env.sim.data.qpos[:].cpu().numpy()
|
|
assert np.all(np.isfinite(qpos))
|
|
env.close()
|
|
|
|
|
|
# Viewer: sameframe shortcut fix.
|
|
|
|
|
|
def _viewer_regression_sphere_spec() -> mujoco.MjSpec:
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "sphere"
|
|
m.make_sphere(subdivision=3)
|
|
m.scale[:] = (0.05, 0.05, 0.05)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "sphere"
|
|
return spec
|
|
|
|
|
|
def _viewer_regression_cone_spec() -> mujoco.MjSpec:
|
|
spec = mujoco.MjSpec()
|
|
m = spec.add_mesh()
|
|
m.name = "cone"
|
|
m.make_cone(nedge=16, radius=0.04)
|
|
m.scale[:] = (0.05, 0.05, 0.05)
|
|
body = spec.worldbody.add_body()
|
|
body.name = "prop"
|
|
body.add_freejoint()
|
|
g = body.add_geom()
|
|
g.name = "visual"
|
|
g.type = mujoco.mjtGeom.mjGEOM_MESH
|
|
g.meshname = "cone"
|
|
return spec
|
|
|
|
|
|
def test_sameframe_fix_makes_host_forward_match_variant():
|
|
"""Clearing sameframe shortcuts aligns host mj_forward with variant."""
|
|
base_model = _viewer_regression_sphere_spec().compile()
|
|
cone_model = _viewer_regression_cone_spec().compile()
|
|
|
|
# Sync cone's kinematic fields onto sphere's model (like viewer does).
|
|
for field in (
|
|
"geom_size",
|
|
"geom_pos",
|
|
"geom_quat",
|
|
"body_mass",
|
|
"body_inertia",
|
|
"body_ipos",
|
|
"body_iquat",
|
|
):
|
|
getattr(base_model, field)[:] = getattr(cone_model, field)
|
|
|
|
base_data = mujoco.MjData(base_model)
|
|
base_data.qpos[:] = cone_model.qpos0
|
|
base_data.qpos[2] = 0.05
|
|
mujoco.mj_forward(base_model, base_data)
|
|
|
|
cone_data = mujoco.MjData(cone_model)
|
|
cone_data.qpos[:] = cone_model.qpos0
|
|
cone_data.qpos[2] = 0.05
|
|
mujoco.mj_forward(cone_model, cone_data)
|
|
|
|
# Before fix: positions differ due to stale sameframe flags.
|
|
assert not np.allclose(base_data.geom_xpos, cone_data.geom_xpos)
|
|
|
|
# After fix: clearing sameframe makes them match.
|
|
disable_model_sameframe_shortcuts(base_model)
|
|
mujoco.mj_forward(base_model, base_data)
|
|
np.testing.assert_allclose(base_data.geom_xpos, cone_data.geom_xpos, atol=1e-6)
|
|
|
|
|
|
def test_sync_model_fields_copies_only_requested_env_fields():
|
|
"""Viewer model sync copies explicit fields and leaves others unchanged."""
|
|
model = _simple_sphere_spec().compile()
|
|
|
|
class _SimModel:
|
|
geom_rgba = torch.tensor(
|
|
[
|
|
[[0.1, 0.2, 0.3, 0.4]],
|
|
[[0.5, 0.6, 0.7, 0.8]],
|
|
],
|
|
dtype=torch.float32,
|
|
)
|
|
geom_pos = torch.tensor(
|
|
[
|
|
[[1.0, 2.0, 3.0]],
|
|
[[4.0, 5.0, 6.0]],
|
|
],
|
|
dtype=torch.float32,
|
|
)
|
|
|
|
original_geom_pos = model.geom_pos.copy()
|
|
|
|
sync_model_fields(model, _SimModel(), {"geom_rgba"}, env_idx=1)
|
|
|
|
np.testing.assert_allclose(model.geom_rgba, [[0.5, 0.6, 0.7, 0.8]])
|
|
np.testing.assert_allclose(model.geom_pos, original_geom_pos)
|