Add SO3 transmission and native orientation actuator.

https://youtu.be/17XpwnqyCXs

New transmission type mjTRN_SO3: a relative orientation, targeting a ball
joint or a site+refsite pair. It is the first transmission with more than
one force output: its length is the norm of the expmap vector of the
relative rotation and its moment axes are the 3 rows of the
relative rotational Jacobian, without projecting onto per-actuator gears.

New force law mjGAIN_SO3/mjBIAS_SO3: a geodesic PD servo, force =
kp * log(q_current^-1 * q_target) - kv * velocity, exact for arbitrary axis
combinations with a unique equilibrium at every commanded orientation.
Error, moment rows and velocity all live in the child frame (joint or
site): the right-difference error is the gradient of the geodesic
potential in that frame. The parent-frame (left) error is not: driving
child-frame torques with it pumps energy at large angles, settling into
steady-spinning limit cycles (the SO3LargeAngleConvergence test). The
integrator variant stores the 3D orientation setpoint in act (actnum = 3,
re-anchored to a bounded representative at integration time). Exposed in
MJCF as <orientation joint=|site=+refsite= kp kv|dampratio>, or via
<general gaintype="so3" biastype="so3">.

The setpoint input has two charts: an expmap target (3 controls, default)
or a quaternion target (4 controls) -- <orientation input="quat">, the
first actuator with different input and output widths. The signature is
recorded in a new per-actuator field actuator_ctrlspec (mjtCtrlChart),
whose meaning is scoped by the gain type the way gain/bias parameters are;
ctrlnum is derived from it at compile time and remains the layout
authority. An explicit field rather than width inference or a prm slot:
width-as-chart cannot express same-width signatures (upcoming servo input
subsets), and prm slots are the input_mode pattern this stack retires.
The force law normalizes the commanded quaternion, making it scale- and
antipodally-invariant. The all-zero ctrl still maps to the identity via
mju_normalize4, but it is a degenerate point (a nudge of any component
commands a half-turn), so quat inputs reset to the identity quaternion:
new mj_resetCtrl sets neutral ctrl values (zero, except qw = 1), called
by mj_resetData and the viewers' Clear All. The quat chart is
restricted to dyntype 'none': integrating a quaternion setpoint linearly
is not meaningful on the manifold. New mjsActuator.ctrlspec field carries
the signature through the spec and XML round-trip.

Actuator sensors (actuatorpos/vel/frc) now report one value per force
output; dim = 3 on an SO3 actuator.

As the first actuator with nu != nactuator, this commit also makes the
viewers multi-input aware: the control sliders in simulate and studio,
which indexed per-actuator arrays by control index (out of bounds on
this model class), are generated per control and labeled with the
actuator name plus an input suffix ("orient/qw"), via the new
introspection helper mj_actuatorInputName -- the single source of truth
for input names, extended by each new multi-input type (quaternion
components are w-first: qw, qx, qy, qz). Slider ranges now honor a
defined ctrlrange even when ctrllimited is false: range is the UI hint,
limited is the clamp -- wrapped and expmap setpoints are unbounded but
still want finite sliders, while quat components are truly bounded.

The rotational demo model is orientation.xml under
test/engine/testdata/actuation/, upgraded to a three-way contrast:
per-axis wrapped servos vs an expmap-commanded vs a quat-commanded
orientation actuator, on identical checker-textured boxes. It is loaded
by the mixed-axis contrast and input-name tests, and doubles as the
viewer test model (slider groups of 3 independent, 3 grouped, 4 grouped).

PiperOrigin-RevId: 951607063
Change-Id: If235dba8e2f2ca72672e7c62531a27e967c6a373
This commit is contained in:
Yuval Tassa
2026-07-21 11:35:28 -07:00
committed by Copybara-Service
parent a8545ac7cc
commit 072e963fa0
49 changed files with 1772 additions and 104 deletions
+2
View File
@@ -166,6 +166,7 @@ PYBIND11_MODULE(_functions, pymodule, pybind11::mod_gil_not_used()) {
InterceptMjErrors(::mj_copyData)(dest, m, src);
});
Def<traits::mj_resetData>(pymodule);
Def<traits::mj_resetCtrl>(pymodule);
Def<traits::mj_resetDataDebug>(pymodule);
Def<traits::mj_resetDataKeyframe>(pymodule);
// Skipped: mj_stackAllocByte (doesn't make sense in Python)
@@ -589,6 +590,7 @@ PYBIND11_MODULE(_functions, pymodule, pybind11::mod_gil_not_used()) {
});
Def<traits::mj_name2id>(pymodule);
Def<traits::mj_id2name>(pymodule);
Def<traits::mj_actuatorInputName>(pymodule);
Def<traits::mj_fullM>(
pymodule,
[](const raw::MjModel* m, const raw::MjData* d,
+14 -2
View File
@@ -251,6 +251,7 @@ ENUMS: Mapping[str, EnumDecl] = dict([
('mjTRN_TENDON', 3),
('mjTRN_SITE', 4),
('mjTRN_BODY', 5),
('mjTRN_SO3', 6),
('mjTRN_UNDEFINED', 1000),
]),
)),
@@ -277,7 +278,8 @@ ENUMS: Mapping[str, EnumDecl] = dict([
('mjGAIN_AFFINE', 1),
('mjGAIN_MUSCLE', 2),
('mjGAIN_DCMOTOR', 3),
('mjGAIN_USER', 4),
('mjGAIN_SO3', 4),
('mjGAIN_USER', 5),
]),
)),
('mjtBias',
@@ -289,7 +291,17 @@ ENUMS: Mapping[str, EnumDecl] = dict([
('mjBIAS_AFFINE', 1),
('mjBIAS_MUSCLE', 2),
('mjBIAS_DCMOTOR', 3),
('mjBIAS_USER', 4),
('mjBIAS_SO3', 4),
('mjBIAS_USER', 5),
]),
)),
('mjtCtrlChart',
EnumDecl(
name='mjtCtrlChart',
declname='enum mjtCtrlChart',
values=dict([
('mjCHART_EXPMAP', 1),
('mjCHART_QUAT', 2),
]),
)),
('mjtObj',
+82
View File
@@ -1102,6 +1102,26 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([
),
doc='Copy mjData, skip large arrays not required for visualization.',
)),
('mj_resetCtrl',
FunctionDecl(
name='mj_resetCtrl',
return_type=ValueType(name='void'),
parameters=(
FunctionParameterDecl(
name='m',
type=PointerType(
inner_type=ValueType(name='mjModel', is_const=True),
),
),
FunctionParameterDecl(
name='d',
type=PointerType(
inner_type=ValueType(name='mjData'),
),
),
),
doc='Reset ctrl to neutral values: zero, except quaternion inputs which reset to the identity.', # pylint: disable=line-too-long
)),
('mj_resetData',
FunctionDecl(
name='mj_resetData',
@@ -3467,6 +3487,30 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([
),
doc='Get name of object with the specified mjtObj type and id; return NULL if name not found.', # pylint: disable=line-too-long
)),
('mj_actuatorInputName',
FunctionDecl(
name='mj_actuatorInputName',
return_type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
parameters=(
FunctionParameterDecl(
name='m',
type=PointerType(
inner_type=ValueType(name='mjModel', is_const=True),
),
),
FunctionParameterDecl(
name='id',
type=ValueType(name='int'),
),
FunctionParameterDecl(
name='input',
type=ValueType(name='int'),
),
),
doc='Get name of actuator input, determined by the actuator type and input signature; return NULL if the actuator type defines no input names.', # pylint: disable=line-too-long
)),
('mj_fullM',
FunctionDecl(
name='mj_fullM',
@@ -10927,6 +10971,44 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([
),
doc='Set actuator to velocity servo; return error if any.',
)),
('mjs_setToOrientation',
FunctionDecl(
name='mjs_setToOrientation',
return_type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
parameters=(
FunctionParameterDecl(
name='actuator',
type=PointerType(
inner_type=ValueType(name='mjsActuator'),
),
),
FunctionParameterDecl(
name='kp',
type=ValueType(name='double'),
),
FunctionParameterDecl(
name='kv',
type=ArrayType(
inner_type=ValueType(name='double'),
extents=(1,),
),
),
FunctionParameterDecl(
name='dampratio',
type=ArrayType(
inner_type=ValueType(name='double'),
extents=(1,),
),
),
FunctionParameterDecl(
name='ctrlspec',
type=ValueType(name='int'),
),
),
doc='Set actuator to orientation servo.',
)),
('mjs_setToDamper',
FunctionDecl(
name='mjs_setToDamper',
+29 -16
View File
@@ -4452,6 +4452,14 @@ STRUCTS: Mapping[str, StructDecl] = dict([
doc='number of controls',
array_extent=('nactuator',),
),
StructFieldDecl(
name='actuator_ctrlspec',
type=PointerType(
inner_type=ValueType(name='int'),
),
doc='input signature, scoped by gaintype',
array_extent=('nactuator',),
),
StructFieldDecl(
name='actuator_outadr',
type=PointerType(
@@ -4620,6 +4628,22 @@ STRUCTS: Mapping[str, StructDecl] = dict([
doc='plugin instance id; -1: not a plugin',
array_extent=('nactuator',),
),
StructFieldDecl(
name='actuator_forcelimited',
type=PointerType(
inner_type=ValueType(name='mjtBool'),
),
doc='is force limited',
array_extent=('nactuator',),
),
StructFieldDecl(
name='actuator_forcerange',
type=PointerType(
inner_type=ValueType(name='mjtNum'),
),
doc='range of forces',
array_extent=('nactuator', 2),
),
StructFieldDecl(
name='actuator_ctrllimited',
type=PointerType(
@@ -4644,22 +4668,6 @@ STRUCTS: Mapping[str, StructDecl] = dict([
doc='scale length and transmitted force',
array_extent=('nout', 6),
),
StructFieldDecl(
name='actuator_forcelimited',
type=PointerType(
inner_type=ValueType(name='mjtBool'),
),
doc='is force limited',
array_extent=('nout',),
),
StructFieldDecl(
name='actuator_forcerange',
type=PointerType(
inner_type=ValueType(name='mjtNum'),
),
doc='range of forces',
array_extent=('nout', 2),
),
StructFieldDecl(
name='actuator_acc0',
type=PointerType(
@@ -9658,6 +9666,11 @@ STRUCTS: Mapping[str, StructDecl] = dict([
type=ValueType(name='int'),
doc='number of activation variables',
),
StructFieldDecl(
name='ctrlspec',
type=ValueType(name='int'),
doc='input signature, scoped by gaintype; 0: type default',
),
StructFieldDecl(
name='actearly',
type=ValueType(name='mjtBool'),
+13
View File
@@ -1554,6 +1554,19 @@ PYBIND11_MODULE(_specs, m, pybind11::mod_gil_not_used()) {
}
},
py::arg("kv"));
mjsActuator.def(
"set_to_orientation",
[](raw::MjsActuator* self, double kp, double kv, double dampratio,
int ctrlspec) {
std::string err = mjs_setToOrientation(
self, kp, kv == -1 ? nullptr : &kv,
dampratio == -1 ? nullptr : &dampratio, ctrlspec);
if (!err.empty()) {
throw pybind11::value_error(err);
}
},
py::arg("kp"), py::arg("kv") = -1, py::arg("dampratio") = -1,
py::arg("ctrlspec") = 0);
mjsActuator.def(
"set_to_damper",
[](raw::MjsActuator* self, double kv) {
+8
View File
@@ -1588,6 +1588,14 @@ class SpecsTest(absltest.TestCase):
self.assertEqual(actuator.biastype, mujoco.mjtBias.mjBIAS_AFFINE)
self.assertEqual(actuator.inheritrange, True)
actuator.set_to_orientation(kp=2.0, dampratio=1.0)
self.assertEqual(actuator.gainprm[0], 2)
self.assertEqual(actuator.biasprm[1], -2)
self.assertEqual(actuator.biasprm[2], 1)
self.assertEqual(actuator.gaintype, mujoco.mjtGain.mjGAIN_SO3)
self.assertEqual(actuator.biastype, mujoco.mjtBias.mjBIAS_SO3)
self.assertEqual(actuator.dyntype, mujoco.mjtDyn.mjDYN_NONE)
actuator.set_to_velocity(kv=5.0)
self.assertEqual(actuator.gainprm[0], 5)
self.assertEqual(actuator.biasprm[2], -5)