diff --git a/newton/_src/sim/builder.py b/newton/_src/sim/builder.py
index 21be7b1d46..4acbb0e056 100644
--- a/newton/_src/sim/builder.py
+++ b/newton/_src/sim/builder.py
@@ -471,6 +471,30 @@ def __init__(self, up_axis: AxisType = Axis.Z, gravity: float = -9.81):
# if setting is None, the number of worst-case number of contacts will be calculated in self.finalize()
self.num_rigid_contacts_per_env = None
+ self.body_armature = self.default_body_armature
+
+ # sites (reference points on bodies for tendons)
+ self.site_key = []
+ self.site_body = []
+ self.site_xform = []
+
+ # tendons
+ self.tendon_key = []
+ self.tendon_type = []
+ self.tendon_site_ids = []
+ self.tendon_damping = []
+ self.tendon_stiffness = []
+ self.tendon_rest_length = []
+
+ # tendon actuators
+ self.tendon_actuator_tendon_id = []
+ self.tendon_actuator_key = []
+ self.tendon_actuator_ke = []
+ self.tendon_actuator_kd = []
+ self.tendon_actuator_force_range = []
+
+ # rigid articulations
+
# equality constraints
self.equality_constraint_type = []
self.equality_constraint_body1 = []
@@ -1155,6 +1179,20 @@ def transform_mul(a, b):
"equality_constraint_polycoef",
"equality_constraint_key",
"equality_constraint_enabled",
+ "site_key",
+ "site_body",
+ "site_xform",
+ "tendon_key",
+ "tendon_type",
+ "tendon_site_ids",
+ "tendon_damping",
+ "tendon_stiffness",
+ "tendon_rest_length",
+ "tendon_actuator_tendon_id",
+ "tendon_actuator_key",
+ "tendon_actuator_ke",
+ "tendon_actuator_kd",
+ "tendon_actuator_force_range",
]
for attr in more_builder_attrs:
@@ -2370,6 +2408,99 @@ def add_muscle(
# return the index of the muscle
return len(self.muscle_start) - 1
+ def add_site(
+ self,
+ body: int,
+ xform: Transform | None = None,
+ key: str | None = None,
+ ) -> int:
+ """
+ Adds a site (reference frame) to the model.
+
+ Args:
+ body: The body index to which the site is attached
+ xform: The transform of the site relative to the body (default: identity)
+ key: Key of the site (optional)
+
+ Returns:
+ The index of the site
+ """
+ if xform is None:
+ xform = wp.transform()
+
+ i = len(self.site_key)
+ self.site_key.append(key or f"site_{i}")
+ self.site_body.append(body)
+ self.site_xform.append(xform)
+
+ return i
+
+ def add_tendon(
+ self,
+ tendon_type: str,
+ site_ids: list[int],
+ damping: float = 0.0,
+ stiffness: float = 0.0,
+ rest_length: float | None = None,
+ key: str | None = None,
+ ) -> int:
+ """
+ Adds a tendon to the model.
+
+ Args:
+ tendon_type: The type of tendon (e.g., 'spatial')
+ site_ids: List of site indices that the tendon connects
+ damping: Damping coefficient for the tendon
+ stiffness: Stiffness coefficient for the tendon
+ rest_length: Rest length of the tendon (default: computed from initial configuration)
+ key: Key of the tendon (optional)
+
+ Returns:
+ The index of the tendon
+ """
+ i = len(self.tendon_key)
+ self.tendon_key.append(key or f"tendon_{i}")
+ self.tendon_type.append(tendon_type)
+ self.tendon_site_ids.append(site_ids)
+ self.tendon_damping.append(damping)
+ self.tendon_stiffness.append(stiffness)
+ self.tendon_rest_length.append(rest_length if rest_length is not None else 0.0)
+
+ return i
+
+ def add_tendon_actuator(
+ self,
+ tendon_id: int,
+ ke: float = 0.0,
+ kd: float = 0.0,
+ force_range: tuple[float, float] | None = None,
+ key: str | None = None,
+ ) -> int:
+ """
+ Adds a tendon actuator to the model.
+
+ Args:
+ tendon_id: The index of the tendon to actuate
+ ke: Elastic/stiffness gain for the actuator
+ kd: Damping gain for the actuator
+ force_range: Force range [min, max] for the actuator (default: [-inf, inf])
+ key: Key of the actuator (optional)
+
+ Returns:
+ The index of the tendon actuator
+ """
+ if force_range is None:
+ force_range = (-float("inf"), float("inf"))
+
+ i = len(self.tendon_actuator_key)
+ self.tendon_actuator_tendon_id.append(tendon_id)
+ self.tendon_actuator_key.append(key or f"tendon_actuator_{i}")
+ self.tendon_actuator_ke.append(ke)
+ self.tendon_actuator_kd.append(kd)
+ self.tendon_actuator_force_range.append(force_range)
+
+ return i
+
# region shapes
def add_shape(
@@ -4213,6 +4344,43 @@ def finalize(self, device: Devicelike | None = None, requires_grad: bool = False
m.muscle_points = wp.array(self.muscle_points, dtype=wp.vec3, requires_grad=requires_grad)
m.muscle_activations = wp.array(self.muscle_activations, dtype=wp.float32, requires_grad=requires_grad)
+ # -----------------------
+ # sites, tendons, and tendon actuators
+
+ m.site_key = self.site_key
+ m.site_body = wp.array(self.site_body, dtype=wp.int32) if self.site_body else None
+ m.site_xform = wp.array(self.site_xform, dtype=wp.transform) if self.site_xform else None
+
+ m.tendon_key = self.tendon_key
+ m.tendon_type = self.tendon_type
+ m.tendon_site_ids = self.tendon_site_ids
+ m.tendon_damping = wp.array(self.tendon_damping, dtype=wp.float32) if self.tendon_damping else None
+ m.tendon_stiffness = wp.array(self.tendon_stiffness, dtype=wp.float32) if self.tendon_stiffness else None
+ m.tendon_rest_length = (
+ wp.array(self.tendon_rest_length, dtype=wp.float32) if self.tendon_rest_length else None
+ )
+
+ m.tendon_actuator_tendon_id = (
+ wp.array(self.tendon_actuator_tendon_id, dtype=wp.int32) if self.tendon_actuator_tendon_id else None
+ )
+ m.tendon_actuator_key = self.tendon_actuator_key
+ m.tendon_actuator_ke = (
+ wp.array(self.tendon_actuator_ke, dtype=wp.float32) if self.tendon_actuator_ke else None
+ )
+ m.tendon_actuator_kd = (
+ wp.array(self.tendon_actuator_kd, dtype=wp.float32) if self.tendon_actuator_kd else None
+ )
+ m.tendon_actuator_force_range = (
+ wp.array(self.tendon_actuator_force_range, dtype=wp.vec2) if self.tendon_actuator_force_range else None
+ )
+
+ # Initialize tendon control arrays
+ if self.tendon_actuator_key:
+ # Initialize with zeros (no default targets for tendons unlike joints)
+ m.tendon_target = wp.zeros(
+ len(self.tendon_actuator_key), dtype=wp.float32, device=device, requires_grad=requires_grad
+ )
+
# --------------------------------------
# rigid bodies
@@ -4392,6 +4560,9 @@ def finalize(self, device: Devicelike | None = None, requires_grad: bool = False
m.edge_count = len(self.edge_rest_angle)
m.spring_count = len(self.spring_rest_length)
m.muscle_count = len(self.muscle_start)
+ m.site_count = len(self.site_key)
+ m.tendon_count = len(self.tendon_key)
+ m.tendon_actuator_count = len(self.tendon_actuator_key)
m.articulation_count = len(self.articulation_start)
m.equality_constraint_count = len(self.equality_constraint_type)
diff --git a/newton/_src/sim/control.py b/newton/_src/sim/control.py
index 7605ca46fe..e2c7cb1601 100644
--- a/newton/_src/sim/control.py
+++ b/newton/_src/sim/control.py
@@ -50,6 +50,13 @@ def __init__(self):
The joint targets are defined for any joint type, except for free joints.
"""
+ self.tendon_target: wp.array | None = None
+ """
+ Array of tendon targets with shape ``(tendon_count,)`` and type ``float``.
+ Tendon targets define the target length or target velocity for each tendon actuator,
+ depending on the tendon actuator configuration.
+ """
+
self.tri_activations: wp.array | None = None
"""Array of triangle element activations with shape ``(tri_count,)`` and type ``float``."""
diff --git a/newton/_src/sim/model.py b/newton/_src/sim/model.py
index 0acbdb6c1f..156e4a174a 100644
--- a/newton/_src/sim/model.py
+++ b/newton/_src/sim/model.py
@@ -203,6 +203,37 @@ def __init__(self, device: Devicelike | None = None):
self.muscle_activations = None
"""Muscle activations, shape [muscle_count], float."""
+ self.site_key = []
+ """List of site keys."""
+ self.site_body = None
+ """Body index for each site, shape [site_count], int."""
+ self.site_xform = None
+ """Transform (position and orientation) for each site relative to its body, shape [site_count, 7], float."""
+
+ self.tendon_key = []
+ """List of tendon keys."""
+ self.tendon_type = []
+ """Type of each tendon ('spatial', etc.), shape [tendon_count], string."""
+ self.tendon_site_ids = []
+ """List of site IDs for each tendon. For spatial tendons, this is a list of site indices."""
+ self.tendon_damping = None
+ """Damping coefficient for each tendon, shape [tendon_count], float."""
+ self.tendon_stiffness = None
+ """Stiffness coefficient for each tendon, shape [tendon_count], float."""
+ self.tendon_rest_length = None
+ """Rest length for each tendon, shape [tendon_count], float."""
+
+ self.tendon_actuator_tendon_id = None
+ """Tendon index for each tendon actuator, shape [tendon_actuator_count], int."""
+ self.tendon_actuator_key = []
+ """List of tendon actuator keys."""
+ self.tendon_actuator_ke = None
+ """Elastic/stiffness gain for each tendon actuator, shape [tendon_actuator_count], float."""
+ self.tendon_actuator_kd = None
+ """Damping gain for each tendon actuator, shape [tendon_actuator_count], float."""
+ self.tendon_actuator_force_range = None
+ """Force range [min, max] for each tendon actuator, shape [tendon_actuator_count, 2], float."""
+
self.body_q = None
"""Rigid body poses for state initialization, shape [body_count, 7], float."""
self.body_qd = None
@@ -230,6 +261,8 @@ def __init__(self, device: Devicelike | None = None):
"""Generalized joint forces for state initialization, shape [joint_dof_count], float."""
self.joint_target = None
"""Generalized joint target inputs, shape [joint_dof_count], float."""
+ self.tendon_target = None
+ """Tendon position target for control, shape [tendon_actuator_count], float."""
self.joint_type = None
"""Joint type, shape [joint_count], int."""
self.joint_parent = None
@@ -362,7 +395,13 @@ def __init__(self, device: Devicelike | None = None):
self.joint_dof_count = 0
"""Total number of velocity degrees of freedom of all joints. Equals the number of joint axes."""
self.joint_coord_count = 0
- """Total number of position degrees of freedom of all joints."""
+ """Total number of position degrees of freedom of all joints in the system."""
+ self.site_count = 0
+ """Total number of sites in the system."""
+ self.tendon_count = 0
+ """Total number of tendons in the system."""
+ self.tendon_actuator_count = 0
+ """Total number of tendon actuators in the system."""
self.equality_constraint_count = 0
"""Total number of equality constraints in the system."""
@@ -437,6 +476,16 @@ def __init__(self, device: Devicelike | None = None):
self.attribute_frequency["shape_scale"] = "shape"
self.attribute_frequency["shape_filter"] = "shape"
+ # attributes per tendon
+ self.attribute_frequency["tendon_damping"] = "tendon"
+ self.attribute_frequency["tendon_stiffness"] = "tendon"
+ self.attribute_frequency["tendon_rest_length"] = "tendon"
+ self.attribute_frequency["tendon_type"] = "tendon"
+ self.attribute_frequency["tendon_target"] = "tendon"
+ self.attribute_frequency["tendon_actuator_kp"] = "tendon"
+ self.attribute_frequency["tendon_actuator_kv"] = "tendon"
+ self.attribute_frequency["tendon_actuator_force_range"] = "tendon"
+
def state(self, requires_grad: bool | None = None) -> State:
"""
Create and return a new :class:`State` object for this model.
@@ -500,12 +549,17 @@ def control(self, requires_grad: bool | None = None, clone_variables: bool = Tru
c.tet_activations = wp.clone(self.tet_activations, requires_grad=requires_grad)
if self.muscle_count:
c.muscle_activations = wp.clone(self.muscle_activations, requires_grad=requires_grad)
+ if self.tendon_actuator_count:
+ c.tendon_target = wp.clone(self.tendon_target, requires_grad=requires_grad)
+
else:
c.joint_target = self.joint_target
c.joint_f = self.joint_f
c.tri_activations = self.tri_activations
c.tet_activations = self.tet_activations
c.muscle_activations = self.muscle_activations
+ c.tendon_target = self.tendon_target
+
return c
def collide(
diff --git a/newton/_src/solvers/mujoco/solver_mujoco.py b/newton/_src/solvers/mujoco/solver_mujoco.py
index f2c3fd8cc1..56be2e04ef 100644
--- a/newton/_src/solvers/mujoco/solver_mujoco.py
+++ b/newton/_src/solvers/mujoco/solver_mujoco.py
@@ -617,6 +617,20 @@ def apply_mjc_qfrc_kernel(
qfrc_applied[worldid, qd_i + i] = joint_f[wqd_i + i]
+@wp.kernel
+def apply_mjc_tendon_control_kernel(
+ tendon_target: wp.array(dtype=wp.float32),
+ tendon_actuator_to_actuator: wp.array(dtype=wp.int32),
+ tendons_per_env: int,
+ # outputs
+ mj_act: wp.array2d(dtype=wp.float32),
+):
+ worldid, tendon_idx = wp.tid()
+ actuator_id = tendon_actuator_to_actuator[tendon_idx]
+ if actuator_id != -1: # Valid mapping
+ mj_act[worldid, actuator_id] = tendon_target[worldid * tendons_per_env + tendon_idx]
+
+
@wp.func
def eval_single_articulation_fk(
joint_start: int,
@@ -1450,6 +1464,31 @@ def apply_mjc_control(self, model: Model, state: State, control: Control | None,
],
device=model.device,
)
+
+ # Apply tendon control if available
+ if control.tendon_target is not None:
+ # Validate tendon_target dimensions
+ if len(control.tendon_target) != model.tendon_actuator_count:
+ raise ValueError(
+ f"Expected {model.tendon_actuator_count} tendon targets, got {len(control.tendon_target)}"
+ )
+ # Apply tendon control using the tendon actuator mapping
+ if hasattr(model, "mjc_tendon_actuator_to_actuator"):
+ tendons_per_env = model.tendon_actuator_count // nworld
+ wp.launch(
+ apply_mjc_tendon_control_kernel,
+ dim=(nworld, tendons_per_env),
+ inputs=[
+ control.tendon_target,
+ model.mjc_tendon_actuator_to_actuator,
+ tendons_per_env,
+ ],
+ outputs=[
+ ctrl,
+ ],
+ device=model.device,
+ )
+
wp.launch(
apply_mjc_qfrc_kernel,
dim=(nworld, joints_per_env),
@@ -1971,6 +2010,8 @@ def fill_arr_from_dict(arr: nparray, d: dict[int, Any]):
# mapping from joint axis to actuator index
axis_to_actuator = np.zeros((model.joint_dof_count,), dtype=np.int32) - 1
actuator_count = 0
+ # mapping from tendon actuator to MuJoCo actuator index
+ tendon_actuator_to_actuator = np.zeros((model.tendon_actuator_count,), dtype=np.int32) - 1
# supported non-fixed joint types in MuJoCo (fixed joints are handled by nesting bodies)
supported_joint_types = {
@@ -2333,6 +2374,103 @@ def add_geoms(newton_body_id: int):
add_geoms(child)
+ # -----------------------
+ # add sites to MuJoCo bodies
+
+ if hasattr(model, "site_key") and model.site_count > 0:
+ site_key = model.site_key
+ site_body = model.site_body.numpy() if model.site_body is not None else []
+ site_xform_array = model.site_xform.numpy() if model.site_xform is not None else []
+
+ for site_idx in range(model.site_count):
+ body_idx = site_body[site_idx]
+ # Extract transform components from numpy array
+ site_tf_data = site_xform_array[site_idx]
+ site_pos = site_tf_data[:3] # First 3 elements are position
+ site_quat = site_tf_data[3:7] # Next 4 elements are quaternion (xyzw)
+
+ # Find the MuJoCo body to attach the site to
+ if body_idx == -1: # Worldbody
+ mj_body = spec.worldbody
+ else:
+ mj_body = mj_bodies[body_mapping[body_idx]]
+
+ # Add the site to the MuJoCo body
+ mj_body.add_site(
+ name=site_key[site_idx],
+ pos=site_pos,
+ quat=quat_to_mjc(site_quat),
+ )
+
+ # -----------------------
+ # add tendons to MuJoCo model
+
+ if hasattr(model, "tendon_key") and model.tendon_count > 0:
+ for tendon_idx in range(model.tendon_count):
+ tendon_key = model.tendon_key[tendon_idx]
+ tendon_type = model.tendon_type[tendon_idx]
+
+ if tendon_type == "spatial":
+ # Add tendon using the correct API
+ tendon = spec.add_tendon(name=tendon_key)
+
+ # Add sites to the tendon using wrap_site
+ site_ids = model.tendon_site_ids[tendon_idx]
+ for site_id in site_ids:
+ tendon.wrap_site(model.site_key[site_id])
+
+ # Set tendon properties if available
+ if model.tendon_damping is not None:
+ tendon.damping = float(model.tendon_damping.numpy()[tendon_idx])
+ if model.tendon_stiffness is not None:
+ tendon.stiffness = float(model.tendon_stiffness.numpy()[tendon_idx])
+ else:
+ wp.utils.warn(
+ f"Tendon type '{tendon_type}' is not supported by MuJoCo solver, skipping tendon '{tendon_key}'"
+ )
+
+ # -----------------------
+ # add tendon actuators
+
+ if hasattr(model, "tendon_actuator_key") and model.tendon_actuator_count > 0:
+ tendon_actuator_ids = (
+ model.tendon_actuator_tendon_id.numpy() if model.tendon_actuator_tendon_id is not None else []
+ )
+ for act_idx in range(model.tendon_actuator_count):
+ tendon_id = int(tendon_actuator_ids[act_idx])
+ tendon_key = model.tendon_key[tendon_id]
+
+ # Create actuator args
+ act_args = {
+ "name": model.tendon_actuator_key[act_idx],
+ "target": tendon_key,
+ "trntype": mujoco.mjtTrn.mjTRN_TENDON,
+ }
+
+ # Set actuator parameters
+ if model.tendon_actuator_ke is not None:
+ ke_values = model.tendon_actuator_ke.numpy()
+ ke = float(ke_values[act_idx])
+ if model.tendon_actuator_kd is not None:
+ kd_values = model.tendon_actuator_kd.numpy()
+ kd = float(kd_values[act_idx])
+ else:
+ kd = 0.0
+
+ # For position control actuators (MuJoCo uses kp/kv terminology)
+ act_args["gainprm"] = [ke, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+ act_args["biasprm"] = [0.0, -ke, -kd, 0, 0, 0, 0, 0, 0, 0]
+
+ # Set force range if available
+ if model.tendon_actuator_force_range is not None:
+ force_range_values = model.tendon_actuator_force_range.numpy()
+ force_range = force_range_values[act_idx]
+ act_args["forcerange"] = [float(force_range[0]), float(force_range[1])]
+
+ spec.add_actuator(**act_args)
+ tendon_actuator_to_actuator[act_idx] = actuator_count
+ actuator_count += 1
+
for i, typ in enumerate(eq_constraint_type):
if typ == EqType.CONNECT:
eq = spec.add_equality(objtype=mujoco.mjtObj.mjOBJ_BODY)
@@ -2409,6 +2547,21 @@ def add_geoms(newton_body_id: int):
geom_to_shape_idx[geom_idx] = shape
with wp.ScopedDevice(model.device):
+ # mapping from Newton joint axis index to MJC actuator index
+ model.mjc_axis_to_actuator = wp.array(axis_to_actuator, dtype=wp.int32) # pyright: ignore[reportAttributeAccessIssue]
+ # mapping from Newton tendon actuator index to MJC actuator index
+ model.mjc_tendon_actuator_to_actuator = wp.array(tendon_actuator_to_actuator, dtype=wp.int32) # pyright: ignore[reportAttributeAccessIssue]
+ # store joint actuator count (before tendon actuators)
+ model.joint_actuator_count = actuator_count - model.tendon_actuator_count # pyright: ignore[reportAttributeAccessIssue]
+ # store total actuator count (including tendon actuators)
+ model.actuator_count = actuator_count # pyright: ignore[reportAttributeAccessIssue]
+ # mapping from MJC body index to Newton body index (skip world index -1)
+ reverse_body_mapping = {v: k for k, v in body_mapping.items()}
+ model.to_mjc_body_index = wp.array( # pyright: ignore[reportAttributeAccessIssue]
+ [reverse_body_mapping[i] + 1 for i in range(1, len(reverse_body_mapping))],
+ dtype=wp.int32,
+ )
+
# create the MuJoCo Warp model
self.mjw_model = mujoco_warp.put_model(self.mj_model)
diff --git a/newton/_src/utils/import_mjcf.py b/newton/_src/utils/import_mjcf.py
index 2c36c9b55b..57a10bf59e 100644
--- a/newton/_src/utils/import_mjcf.py
+++ b/newton/_src/utils/import_mjcf.py
@@ -762,6 +762,19 @@ def parse_body(
builder.body_inertia[link] = I_m
builder.body_inv_inertia[link] = wp.inverse(I_m)
+ # -----------------
+ # parse sites
+
+ for site in body.findall("site"):
+ site_attrib = site.attrib
+ site_name = site_attrib.get("name", f"{body_name}_site_{len(builder.site_key)}")
+ site_pos = parse_vec(site_attrib, "pos", (0.0, 0.0, 0.0)) * scale
+ site_quat = parse_orientation(site_attrib)
+ site_xform = wp.transform(site_pos, site_quat)
+
+ # Add the site to the builder
+ builder.add_site(link, site_xform, key=site_name)
+
# -----------------
# recurse
@@ -907,6 +920,23 @@ def parse_common_attributes(element):
incoming_xform=xform,
)
+ # -----------------
+ # add worldbody sites
+
+ for site in world.findall("site"):
+ site_attrib = site.attrib
+ site_name = site_attrib.get("name", f"world_site_{len(builder.site_key)}")
+ site_pos = parse_vec(site_attrib, "pos", (0.0, 0.0, 0.0)) * scale
+ site_quat = parse_orientation(site_attrib)
+ site_xform = wp.transform(site_pos, site_quat)
+
+ # Apply world transform if provided
+ if xform is not None:
+ site_xform = xform * site_xform
+
+ # Add the site to the builder (worldbody has index -1)
+ builder.add_site(-1, site_xform, key=site_name)
+
# -----------------
# add equality constraints
@@ -927,5 +957,76 @@ def parse_common_attributes(element):
for j in range(i + 1, end_shape_count):
builder.shape_collision_filter_pairs.append((i, j))
+ # -----------------
+ # parse tendons
+
+ # Create a mapping from site names to site indices
+ site_name_to_index = {name: i for i, name in enumerate(builder.site_key)}
+
+ tendon_root = root.find("tendon")
+ if tendon_root is not None:
+ for spatial_tendon in tendon_root.findall("spatial"):
+ tendon_name = spatial_tendon.attrib.get("name", f"spatial_tendon_{len(builder.tendon_key)}")
+
+ # Parse tendon properties
+ damping = parse_float(spatial_tendon.attrib, "damping", 0.0)
+ stiffness = parse_float(spatial_tendon.attrib, "stiffness", 0.0)
+
+ # Collect site indices for this tendon
+ site_ids = []
+ for site_element in spatial_tendon.findall("site"):
+ site_attr = site_element.attrib.get("site")
+ if site_attr and site_attr in site_name_to_index:
+ site_ids.append(site_name_to_index[site_attr])
+ else:
+ print(f"Warning: Site '{site_attr}' referenced in tendon '{tendon_name}' not found")
+
+ if len(site_ids) >= 2: # A tendon needs at least 2 sites
+ builder.add_tendon(
+ tendon_type="spatial",
+ site_ids=site_ids,
+ damping=damping,
+ stiffness=stiffness,
+ key=tendon_name,
+ )
+ else:
+ print(f"Warning: Tendon '{tendon_name}' has fewer than 2 sites, skipping")
+
+ # -----------------
+ # parse actuators
+
+ # Create a mapping from tendon names to tendon indices
+ tendon_name_to_index = {name: i for i, name in enumerate(builder.tendon_key)}
+
+ actuator_root = root.find("actuator")
+ if actuator_root is not None:
+ for actuator in actuator_root:
+ # Check if this actuator references a tendon
+ tendon_ref = actuator.attrib.get("tendon")
+ if tendon_ref and tendon_ref in tendon_name_to_index:
+ actuator_name = actuator.attrib.get("name", f"tendon_actuator_{len(builder.tendon_actuator_key)}")
+ tendon_id = tendon_name_to_index[tendon_ref]
+
+ # Parse actuator properties
+ # For position actuators, kp maps to ke (elastic gain)
+ ke = parse_float(actuator.attrib, "kp", 0.0)
+ kd = parse_float(actuator.attrib, "kv", 0.0)
+
+ # Parse force range if specified
+ forcerange = actuator.attrib.get("forcerange")
+ if forcerange:
+ force_vals = np.fromstring(forcerange, sep=" ", dtype=np.float32)
+ force_range = (force_vals[0], force_vals[1])
+ else:
+ force_range = None
+
+ builder.add_tendon_actuator(
+ tendon_id=tendon_id,
+ ke=ke,
+ kd=kd,
+ force_range=force_range,
+ key=actuator_name,
+ )
+
if collapse_fixed_joints:
builder.collapse_fixed_joints()
diff --git a/newton/_src/utils/import_usd.py b/newton/_src/utils/import_usd.py
index 6a74fd5b8f..d032678c46 100644
--- a/newton/_src/utils/import_usd.py
+++ b/newton/_src/utils/import_usd.py
@@ -734,6 +734,50 @@ def define_joint_mode(dof, joint_desc):
else:
raise NotImplementedError(f"Unsupported joint type {key}")
+ def parse_tendon_sites(sites_dict):
+ nonlocal path_body_map
+ # find the leafs of a tendon by finding the only children who aren't parents
+ sites = set(sites_dict.keys())
+ site_parents = set()
+ for site in sites_dict.values():
+ if "parentAttachment" in site.keys():
+ site_parents.add(site["parentAttachment"])
+ tendon_leafs = sites - site_parents
+
+ # traverse all sites in a tendon, adding each to the model
+ for i, leaf in enumerate(tendon_leafs):
+ root_found = False
+
+ site_pos = wp.transform(
+ wp.vec3(*sites_dict[leaf]["localPos"] if "localPos" in sites_dict[leaf] else [0, 0, 0])
+ )
+ if path_body_map[sites_dict[leaf]["body_path"]] < 0:
+ site_pos = builder.shape_transform[path_body_map[sites_dict[leaf]["body_path"]] + 1] * site_pos
+
+ tendon_sites = [builder.add_site(path_body_map[sites_dict[leaf]["body_path"]], site_pos, leaf)]
+
+ curr_site = sites_dict[leaf]["parentAttachment"]
+ while not root_found:
+ curr_site_pos = wp.transform(
+ wp.vec3(*sites_dict[curr_site]["localPos"] if "localPos" in sites_dict[curr_site] else [0, 0, 0])
+ )
+ if path_body_map[sites_dict[curr_site]["body_path"]] < 0:
+ curr_site_pos = (
+ builder.shape_transform[path_body_map[sites_dict[leaf]["body_path"]] + 1] * curr_site_pos
+ )
+ tendon_sites.append(
+ builder.add_site(path_body_map[sites_dict[curr_site]["body_path"]], curr_site_pos, curr_site)
+ )
+
+ if "parentAttachment" in sites_dict[curr_site].keys():
+ curr_site = sites_dict[curr_site]["parentAttachment"]
+ else:
+ root_found = True
+ tendon = builder.add_tendon(tendon_type="spatial", site_ids=tendon_sites, key=f"tendon{i}")
+ stiffness = sites_dict[curr_site]["stiffness"] if "stiffness" in sites_dict[curr_site] else 0.0
+ damping = sites_dict[curr_site]["damping"] if "damping" in sites_dict[curr_site] else 0.0
+ builder.add_tendon_actuator(tendon, ke=stiffness, kd=damping, key=f"tendon{i}_act")
+
# Looking for and parsing the attributes on PhysicsScene prims
scene_attributes = {}
if UsdPhysics.ObjectType.Scene in ret_dict:
@@ -793,6 +837,8 @@ def define_joint_mode(dof, joint_desc):
articulation_bodies = {}
articulation_roots = []
+ tendon_sites_info = {}
+
# TODO: uniform interface for iterating
def data_for_key(physics_utils_results, key):
if key not in physics_utils_results:
@@ -853,6 +899,25 @@ def warn_invalid_desc(path, descriptor) -> bool:
body_density[body_path] = density
# <--- Marking for deprecation
+ # accumulate all tendon site info in the scene in a single dict
+ # grabs attribute info instead of requiring Physx schema be present
+ # NOTE: does not take into account fixed tendons
+ sites = {}
+ for attrib in prim.GetAuthoredPropertiesInNamespace("physxTendon"):
+ split_name = attrib.SplitName()
+ if split_name[1] not in sites:
+ sites[split_name[1]] = {}
+ sites[split_name[1]]["body_path"] = body_path
+
+ # filter out relationships
+ if split_name[2] != "parentLink":
+ sites[split_name[1]][split_name[2]] = attrib.Get()
+ for site in sites.keys():
+ if site not in tendon_sites_info:
+ tendon_sites_info[site] = sites[site]
+ else:
+ raise TypeError("Non-unique site name found in USD scene. This is incompatible with Mujoco")
+
# maps from articulation_id to bool indicating if self-collisions are enabled
articulation_has_self_collision = {}
@@ -1420,6 +1485,8 @@ def warn_invalid_desc(path, descriptor) -> bool:
builder = multi_env_builder
+ parse_tendon_sites(tendon_sites_info) # do this after collapsing path_body_map
+
return {
"fps": stage.GetFramesPerSecond(),
"duration": stage.GetEndTimeCode() - stage.GetStartTimeCode(),
diff --git a/newton/_src/utils/selection.py b/newton/_src/utils/selection.py
index 55e0bf0820..b3929d379e 100644
--- a/newton/_src/utils/selection.py
+++ b/newton/_src/utils/selection.py
@@ -151,6 +151,8 @@ def __init__(
exclude_links: list[str | int] | None = None,
include_joint_types: list[int] | None = None,
exclude_joint_types: list[int] | None = None,
+ include_tendons: list[str | int] | None = None,
+ exclude_tendons: list[str | int] | None = None,
verbose: bool | None = None,
):
self.model = model
@@ -175,6 +177,7 @@ def __init__(
model_joint_q_start = model.joint_q_start.numpy()
model_joint_qd_start = model.joint_qd_start.numpy()
model_shape_body = model.shape_body.numpy()
+ model_tendon_count = model.tendon_actuator_count
# FIXME:
# - this assumes homogeneous envs with one selected articulation per env
@@ -192,6 +195,8 @@ def __init__(
arti_joint_types = []
arti_link_ids = []
arti_link_names = []
+ arti_tendon_indices = []
+ arti_tendon_names = []
def get_name_from_key(key):
return key.split("/")[-1]
@@ -205,6 +210,10 @@ def get_name_from_key(key):
arti_link_ids.append(int(link_id))
arti_link_names.append(get_name_from_key(model.body_key[link_id]))
+ for idx in range(model_tendon_count):
+ arti_tendon_indices.append(int(idx))
+ arti_tendon_names.append(get_name_from_key(model.tendon_key[idx]))
+
# create joint inclusion set
if include_joints is None and include_joint_types is None:
joint_include_indices = set(range(arti_joint_count))
@@ -275,15 +284,46 @@ def get_name_from_key(key):
else:
raise TypeError(f"Link ids must be strings or integers, got {id} of type {type(id)}")
+ if include_tendons is None:
+ tendon_include_indices = set(range(model_tendon_count))
+ else:
+ tendon_include_indices = set()
+ if include_tendons is not None:
+ for id in include_tendons:
+ if isinstance(id, str):
+ for idx, name in enumerate(arti_tendon_names):
+ if fnmatch(name, id):
+ tendon_include_indices.add(idx)
+ elif isinstance(id, int):
+ if id >= 0 and id < model_tendon_count:
+ tendon_include_indices.add(id)
+ else:
+ raise TypeError(f"Tendon ids must be strings or integers, got {id} of type {type(id)}")
+
+ tendon_exclude_indices = set()
+ if exclude_tendons is not None:
+ for id in exclude_tendons:
+ if isinstance(id, str):
+ for idx, name in enumerate(arti_tendon_names):
+ if fnmatch(name, id):
+ tendon_exclude_indices.add(idx)
+ elif isinstance(id, int):
+ if id >= 0 and id < model_tendon_count:
+ tendon_exclude_indices.add(id)
+ else:
+ raise TypeError(f"Tendon ids must be strings or integers, got {id} of type {type(id)}")
+
# compute selected indices
selected_joint_indices = sorted(joint_include_indices - joint_exclude_indices)
selected_link_indices = sorted(link_include_indices - link_exclude_indices)
+ selected_tendon_indices = sorted(tendon_include_indices - tendon_exclude_indices)
selected_joint_ids = []
selected_joint_dof_ids = []
selected_joint_coord_ids = []
selected_link_ids = []
selected_shape_ids = []
+ selected_tendon_ids = []
self.joint_names = []
self.joint_dof_names = []
@@ -293,6 +333,7 @@ def get_name_from_key(key):
self.body_names = []
self.shape_names = []
self.body_shapes = []
+ self.tendon_names = []
# populate info for selected joints and dofs
for idx in selected_joint_indices:
@@ -353,6 +394,10 @@ def get_name_from_key(key):
self.shape_names.append(get_name_from_key(model.shape_key[shape_id]))
self.body_shapes.append(shape_index_list)
+ for idx in selected_tendon_indices:
+ selected_tendon_ids.append(arti_tendon_indices[idx])
+ self.tendon_names.append(arti_tendon_names[idx])
+
# selected counts
self.count = articulation_count
self.joint_count = len(selected_joint_ids)
@@ -360,6 +405,7 @@ def get_name_from_key(key):
self.joint_coord_count = len(selected_joint_coord_ids)
self.link_count = len(selected_link_ids)
self.shape_count = len(selected_shape_ids)
+ self.spatial_tendon_count = len(selected_tendon_ids)
# support custom slicing and indexing
self._arti_joint_begin = int(arti_joint_begin)
@@ -389,6 +435,7 @@ def is_contiguous_slice(indices):
self.joint_coords_contiguous = is_contiguous_slice(selected_joint_coord_ids)
self.links_contiguous = is_contiguous_slice(selected_link_ids)
self.shapes_contiguous = is_contiguous_slice(selected_shape_ids)
+ self.tendons_contiguous = is_contiguous_slice(selected_tendon_ids)
# contiguous slices or indices by attribute frequency
#
@@ -444,6 +491,11 @@ def is_contiguous_slice(indices):
else:
self._frequency_indices["shape"] = wp.array(selected_shape_ids, dtype=int, device=self.device)
+ if self.tendons_contiguous and len(selected_tendon_ids) > 0:
+ self._frequency_slices["tendon"] = slice(selected_tendon_ids[0], selected_tendon_ids[-1] + 1)
+ else:
+ self._frequency_indices["tendon"] = wp.array(selected_tendon_ids, dtype=int, device=self.device)
+
self.articulation_indices = wp.array(articulation_ids, dtype=int, device=self.device)
# TODO: zero-stride mask would use less memory
@@ -795,6 +847,12 @@ def set_dof_forces(self, target: "Control", values, mask=None):
"""
self._set_attribute_values("joint_f", target, values, mask=mask)
+ def set_spatial_tendon_properties(self, target: Control, stiffness, damping, limit_stiffness, offset, mask=None):
+ self._set_attribute_values("tendon_target", target, offset)
+
+ def get_spatial_tendon_offsets(self, source: Control):
+ return self._get_attribute_values("tendon_target", source)
+
# ========================================================================================
# Utilities
diff --git a/newton/examples/example_mjwarp_tendon.py b/newton/examples/example_mjwarp_tendon.py
new file mode 100644
index 0000000000..644870d18c
--- /dev/null
+++ b/newton/examples/example_mjwarp_tendon.py
@@ -0,0 +1,215 @@
+import os
+import tempfile
+
+import numpy as np
+import warp as wp
+
+import newton
+import newton.solvers
+import newton.utils
+
+
+class Example:
+ def __init__(self, stage_path="example_mjwarp_tendon.usd", num_frames=300, headless=False):
+ # Simulation parameters
+ self.sim_time = 0.0
+ self.frame_dt = 1.0 / 60 # 60 FPS
+ self.sim_substeps = 5
+ self.sim_dt = self.frame_dt / self.sim_substeps
+ self.num_frames = num_frames
+
+ # MJCF content with tendon
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ # Create temporary MJCF file
+ self.tmpdir = tempfile.TemporaryDirectory()
+ mjcf_path = os.path.join(self.tmpdir.name, "test-tendon.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Build model
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(
+ mjcf_path,
+ collapse_fixed_joints=True,
+ up_axis="Z",
+ enable_self_collisions=False,
+ )
+ self.model = builder.finalize()
+
+ print("Model statistics:")
+ print(f" Sites: {self.model.site_count}")
+ print(f" Tendons: {self.model.tendon_count}")
+ print(f" Tendon actuators: {self.model.tendon_actuator_count}")
+
+ # Create solver
+ self.solver = newton.solvers.SolverMuJoCo(self.model)
+ print("\nMuJoCo solver created successfully!")
+
+ # Create states and control
+ self.state_0 = self.model.state()
+ self.state_1 = self.model.state()
+ self.control = self.model.control()
+
+ # Create renderer
+ if not headless:
+ self.renderer = newton.viewer.RendererOpenGL(
+ model=self.model,
+ path=stage_path,
+ scaling=1.0,
+ up_axis="Z",
+ screen_width=1280,
+ screen_height=720,
+ camera_pos=(0, 1, 3), # View from negative Y direction, looking at the pendulum
+ )
+ elif stage_path:
+ self.renderer = newton.viewer.RendererUsd(self.model, stage_path)
+ else:
+ self.renderer = None
+
+ # Set initial tendon target to contract the cable
+ tendon_targets = self.control.tendon_target.numpy()
+ tendon_targets[0] = -0.3 # Contract by 30cm
+ self.control.tendon_target = wp.array(tendon_targets, dtype=wp.float32, device=self.model.device)
+ print(f"Set tendon target to: {tendon_targets[0]}")
+
+ # Record initial state
+ self.initial_angle = self.state_0.joint_q.numpy()[0]
+
+ def simulate(self):
+ for _ in range(self.sim_substeps):
+ self.solver.step(self.state_0, self.state_1, self.control, None, self.sim_dt)
+ self.state_0, self.state_1 = self.state_1, self.state_0
+
+ def step(self):
+ with wp.ScopedTimer("step", active=False):
+ self.simulate()
+ self.sim_time += self.frame_dt
+
+ def render(self):
+ if self.renderer is None:
+ return
+
+ with wp.ScopedTimer("render", active=False):
+ self.renderer.begin_frame(self.sim_time)
+ self.renderer.render(self.state_0)
+
+ # Visualize the tendon as a line between sites
+ if self.model.site_count >= 2:
+ # Get site positions in world space
+ site_positions = []
+ site_body_np = self.model.site_body.numpy()
+ site_xform_np = self.model.site_xform.numpy()
+ body_q_np = self.state_0.body_q.numpy()
+
+ for i in range(2): # anchor and attach sites
+ body_idx = int(site_body_np[i])
+ site_xform = site_xform_np[i]
+
+ if body_idx >= 0:
+ # Site is attached to a body, transform to world space
+ body_q = body_q_np[body_idx]
+ body_transform = wp.transform(body_q[:3], body_q[3:7])
+ site_world_pos = wp.transform_point(body_transform, wp.vec3(site_xform[:3]))
+ else:
+ # Site is in world space
+ site_world_pos = site_xform[:3]
+
+ site_positions.append(site_world_pos)
+
+ # Render line between sites
+ self.renderer.render_line_strip(
+ "tendon_cable",
+ site_positions,
+ color=(1.0, 0.5, 0.0), # Orange color for the cable
+ radius=0.02,
+ )
+
+ self.renderer.end_frame()
+
+ def run(self):
+ print(f"\nRunning simulation for {self.num_frames} frames...")
+ print("Controls: SPACE to pause, TAB to skip rendering, ESC to exit")
+ print("Camera: Use WASD + mouse drag to move, mouse wheel to zoom")
+ print("Orange cable shows the tendon, gets redder/thicker with more force")
+
+ for i in range(self.num_frames):
+ self.step()
+ self.render()
+
+ # Print status every 0.5 seconds (30 frames at 60 FPS)
+ if i % 30 == 0:
+ angle = self.state_0.joint_q.numpy()[0]
+ velocity = self.state_0.joint_qd.numpy()[0]
+ print(f" t={self.sim_time:.1f}s: angle={np.degrees(angle):6.1f}°, velocity={velocity:6.2f} rad/s")
+
+ # Final results
+ final_angle = self.state_0.joint_q.numpy()[0]
+ angle_change = np.degrees(final_angle - self.initial_angle)
+
+ print("\nFinal result:")
+ print(f" Angle changed by: {angle_change:.1f}°")
+
+ if abs(angle_change) > 1.0:
+ print("✓ SUCCESS: Tendon control is working!")
+ else:
+ print("✗ FAIL: Tendon control is NOT working - pendulum didn't move")
+
+ # Save renderer output
+ if self.renderer and hasattr(self.renderer, "save"):
+ self.renderer.save()
+ print(f"\nAnimation saved to: {self.renderer.stage_path}")
+
+ def __del__(self):
+ # Clean up temporary directory
+ if hasattr(self, "tmpdir") and self.tmpdir:
+ try:
+ self.tmpdir.cleanup()
+ except Exception:
+ pass # Ignore cleanup errors
+
+
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ parser.add_argument("--device", type=str, default=None, help="Override the default Warp device.")
+ parser.add_argument(
+ "--stage-path",
+ type=lambda x: None if x == "None" else str(x),
+ default="example_mjwarp_tendon.usd",
+ help="Path to the output USD file.",
+ )
+ parser.add_argument("--num-frames", type=int, default=300, help="Total number of frames (default: 300).")
+ parser.add_argument("--headless", action="store_true", help="Run in headless mode without visualization.")
+
+ args = parser.parse_known_args()[0]
+
+ with wp.ScopedDevice(args.device):
+ example = Example(stage_path=args.stage_path, num_frames=args.num_frames, headless=args.headless)
+ example.run()
diff --git a/newton/tests/assets/spatial_shadow_hand.usd b/newton/tests/assets/spatial_shadow_hand.usd
new file mode 100644
index 0000000000..b382cf99cb
Binary files /dev/null and b/newton/tests/assets/spatial_shadow_hand.usd differ
diff --git a/newton/tests/test_import_mjcf.py b/newton/tests/test_import_mjcf.py
index 5267fcc35a..85f35b8cb8 100644
--- a/newton/tests/test_import_mjcf.py
+++ b/newton/tests/test_import_mjcf.py
@@ -212,6 +212,171 @@ def test_inertia_rotation(self):
# Verify that the rotation was actually applied (not just identity)
assert not np.allclose(actual_inertia, original_inertia, atol=1e-6)
+ def test_site_parsing(self):
+ """Test that sites are parsed from MJCF files"""
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mjcf_path = os.path.join(tmpdir, "test_sites.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Parse MJCF
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(mjcf_path)
+ model = builder.finalize()
+
+ # Check sites were parsed
+ self.assertEqual(model.site_count, 3)
+ self.assertEqual(len(model.site_key), 3)
+
+ # Check site keys
+ self.assertIn("world_site", model.site_key)
+ self.assertIn("body_site1", model.site_key)
+ self.assertIn("body_site2", model.site_key)
+
+ # Check site bodies (world is -1, body1 is 0)
+ site_bodies = model.site_body.numpy() if model.site_body is not None else []
+ world_site_idx = model.site_key.index("world_site")
+ body_site1_idx = model.site_key.index("body_site1")
+
+ self.assertEqual(site_bodies[world_site_idx], -1) # worldbody
+ self.assertEqual(site_bodies[body_site1_idx], 0) # first body
+
+ def test_tendon_parsing(self):
+ """Test that spatial tendons are parsed from MJCF files"""
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mjcf_path = os.path.join(tmpdir, "test_tendons.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Parse MJCF
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(mjcf_path)
+ model = builder.finalize()
+
+ # Check tendons were parsed
+ self.assertEqual(model.tendon_count, 2)
+ self.assertEqual(len(model.tendon_key), 2)
+
+ # Check tendon keys
+ self.assertIn("tendon1", model.tendon_key)
+ self.assertIn("tendon2", model.tendon_key)
+
+ # Check tendon properties
+ tendon1_idx = model.tendon_key.index("tendon1")
+ self.assertEqual(model.tendon_type[tendon1_idx], "spatial")
+
+ if model.tendon_damping is not None:
+ damping_values = model.tendon_damping.numpy()
+ self.assertAlmostEqual(damping_values[tendon1_idx], 0.5)
+ if model.tendon_stiffness is not None:
+ stiffness_values = model.tendon_stiffness.numpy()
+ self.assertAlmostEqual(stiffness_values[tendon1_idx], 100.0)
+
+ # Check tendon site connections
+ self.assertEqual(len(model.tendon_site_ids[tendon1_idx]), 2) # 2 sites
+ self.assertEqual(len(model.tendon_site_ids[1]), 3) # 3 sites for tendon2
+
+ def test_tendon_actuator_parsing(self):
+ """Test that tendon actuators are parsed from MJCF files"""
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mjcf_path = os.path.join(tmpdir, "test_actuators.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Parse MJCF
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(mjcf_path)
+ model = builder.finalize()
+
+ # Check tendon actuator was parsed
+ self.assertEqual(model.tendon_actuator_count, 1)
+ self.assertEqual(len(model.tendon_actuator_key), 1)
+
+ # Check actuator key
+ self.assertEqual(model.tendon_actuator_key[0], "tendon_act1")
+
+ # Check actuator properties
+ if model.tendon_actuator_ke is not None:
+ ke_values = model.tendon_actuator_ke.numpy()
+ self.assertAlmostEqual(ke_values[0], 300.0)
+ if model.tendon_actuator_kd is not None:
+ kd_values = model.tendon_actuator_kd.numpy()
+ self.assertAlmostEqual(kd_values[0], 10.0)
+ if model.tendon_actuator_force_range is not None:
+ force_range = model.tendon_actuator_force_range.numpy()
+ self.assertAlmostEqual(force_range[0][0], -50.0)
+ self.assertAlmostEqual(force_range[0][1], 50.0)
+
+ # Check actuator references correct tendon
+ tendon_ids = model.tendon_actuator_tendon_id.numpy()
+ tendon_id = tendon_ids[0]
+ self.assertEqual(model.tendon_key[tendon_id], "tendon1")
+
def test_single_body_transform(self):
"""Test 1: Single body with pos/quat → verify body_q matches expected world transform."""
mjcf_content = """
diff --git a/newton/tests/test_import_usd.py b/newton/tests/test_import_usd.py
index df9f801a8f..45181d194c 100644
--- a/newton/tests/test_import_usd.py
+++ b/newton/tests/test_import_usd.py
@@ -656,6 +656,34 @@ def test_force_limits(self):
joint_dof_idx_AD = model.joint_qd_start.numpy()[joint_idx_AD]
self.assertEqual(model.joint_effort_limit.numpy()[joint_dof_idx_AD], 30.0)
+ @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core")
+ def test_spatial_tendon(self):
+ builder = newton.ModelBuilder()
+
+ results = builder.add_usd(
+ os.path.join(os.path.dirname(__file__), "assets", "spatial_shadow_hand.usd"),
+ collapse_fixed_joints=True,
+ )
+ self.assertEqual(builder.body_count, 24)
+ self.assertEqual(builder.shape_count, 47)
+ self.assertEqual(len(builder.shape_key), len(set(builder.shape_key)))
+ self.assertEqual(len(builder.body_key), len(set(builder.body_key)))
+ self.assertEqual(len(builder.joint_key), len(set(builder.joint_key)))
+ # 8 joints + 1 free joint for the root body
+ self.assertEqual(builder.joint_count, 24)
+ self.assertEqual(builder.joint_dof_count, 24)
+ self.assertEqual(builder.joint_coord_count, 24)
+ self.assertEqual(builder.joint_type, [newton.JointType.REVOLUTE] * 24)
+ self.assertEqual(len(results["path_body_map"]), 26)
+ self.assertEqual(len(results["path_shape_map"]), 47)
+
+ collision_shapes = [
+ i for i in range(builder.shape_count) if builder.shape_flags[i] & int(newton.ShapeFlags.COLLIDE_SHAPES)
+ ]
+ self.assertEqual(len(collision_shapes), 22)
+ self.assertEqual(len(builder.site_key), 107)
+ self.assertEqual(len(builder.tendon_key), 28)
+
class TestImportSampleAssets(unittest.TestCase):
def verify_usdphysics_parser(self, file, model, compare_min_max_coords, floating):
diff --git a/newton/tests/test_tendon_control.py b/newton/tests/test_tendon_control.py
new file mode 100644
index 0000000000..6a4a4eeea8
--- /dev/null
+++ b/newton/tests/test_tendon_control.py
@@ -0,0 +1,237 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import tempfile
+import unittest
+
+import warp as wp
+
+import newton
+import newton.examples
+from newton.selection import ArticulationView
+
+
+class TestTendonControl(unittest.TestCase):
+ def test_tendon_control_integration(self):
+ """Test full integration of tendon control from MJCF to simulation"""
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mjcf_path = os.path.join(tmpdir, "test_tendon.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Parse with Newton
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(
+ mjcf_path,
+ collapse_fixed_joints=True,
+ up_axis="Z",
+ enable_self_collisions=False,
+ )
+ model = builder.finalize()
+
+ # Verify model structure
+ self.assertEqual(model.site_count, 2)
+ self.assertEqual(model.tendon_count, 1)
+ self.assertEqual(model.tendon_actuator_count, 1)
+ self.assertEqual(model.joint_count, 1)
+
+ # Create states and control
+ state_0 = model.state()
+ state_1 = model.state()
+ control = model.control()
+
+ # Verify control has tendon arrays
+ self.assertIsNotNone(control.tendon_target)
+ self.assertEqual(len(control.tendon_target), 1)
+
+ # Set tendon target
+ # Need to use numpy to modify warp array
+ tendon_targets = control.tendon_target.numpy()
+ tendon_targets[0] = -0.05 # Contract tendon by 5cm
+ control.tendon_target = wp.array(tendon_targets, dtype=wp.float32, device=model.device)
+
+ # Create solver - let it handle MuJoCo model creation internally
+ solver = newton.solvers.SolverMuJoCo(model)
+
+ # Record initial joint position
+ initial_joint_pos = state_0.joint_q.numpy()[0]
+
+ # Simulate
+ dt = 0.001
+ for _ in range(100):
+ solver.step(state_0, state_1, control, None, dt)
+ state_0, state_1 = state_1, state_0
+
+ # Verify joint moved due to tendon actuation
+ final_joint_pos = state_0.joint_q.numpy()[0]
+ self.assertNotAlmostEqual(initial_joint_pos, final_joint_pos, places=3)
+ # Joint should have rotated (positive direction due to tendon contraction pulling site1 towards site0)
+ self.assertGreater(final_joint_pos, initial_joint_pos)
+
+ def test_control_initialization(self):
+ """Test that tendon control arrays are properly initialized"""
+ builder = newton.ModelBuilder()
+
+ # Add a simple model with tendons
+ builder.add_body(xform=wp.transform())
+ site1 = builder.add_site(0, wp.transform(wp.vec3(0, 0, 0)), key="site1")
+ site2 = builder.add_site(0, wp.transform(wp.vec3(1, 0, 0)), key="site2")
+
+ tendon_id = builder.add_tendon(
+ tendon_type="spatial", site_ids=[site1, site2], stiffness=100.0, key="test_tendon"
+ )
+
+ builder.add_tendon_actuator(tendon_id=tendon_id, ke=50.0, key="test_actuator")
+
+ model = builder.finalize()
+
+ # Check control initialization
+ control = model.control()
+ self.assertIsNotNone(control.tendon_target)
+ self.assertEqual(len(control.tendon_target), 1)
+
+ # Check initial values are zero
+ self.assertEqual(control.tendon_target.numpy()[0], 0.0)
+
+ def test_tendon_control_selection(self):
+ """Test that tendon can be controlled via Selection API"""
+ mjcf_content = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mjcf_path = os.path.join(tmpdir, "test_tendon.xml")
+ with open(mjcf_path, "w") as f:
+ f.write(mjcf_content)
+
+ # Parse with Newton
+ builder = newton.ModelBuilder()
+ builder.add_mjcf(
+ mjcf_path,
+ collapse_fixed_joints=True,
+ up_axis="Z",
+ enable_self_collisions=False,
+ )
+ model = builder.finalize()
+
+ # Verify model structure
+ self.assertEqual(model.site_count, 3)
+ self.assertEqual(model.tendon_count, 2)
+ self.assertEqual(model.tendon_actuator_count, 2)
+ self.assertEqual(model.joint_count, 2)
+
+ # Create states and control
+ state_0 = model.state()
+ state_1 = model.state()
+ control = model.control()
+
+ # Verify control has tendon arrays
+ self.assertIsNotNone(control.tendon_target)
+ self.assertEqual(len(control.tendon_target), 2)
+
+ # Set tendon target via selection
+ tendons = ArticulationView(model, "articulation_1")
+ self.assertEqual(tendons.get_attribute("tendon_target", control).numpy()[0][0], 0)
+ self.assertEqual(tendons.get_attribute("tendon_target", control).numpy()[0][1], 0)
+ tendons.set_attribute("tendon_target", control, [[-1.0, -1.0]])
+ self.assertEqual(tendons.get_attribute("tendon_target", control).numpy()[0][0], -1.0)
+ self.assertEqual(tendons.get_attribute("tendon_target", control).numpy()[0][1], -1.0)
+
+ tendons = ArticulationView(model, "articulation_1", exclude_tendons=[0])
+ tendons.set_attribute("tendon_target", control, [[-1.0]])
+
+ # Create solver - let it handle MuJoCo model creation internally
+ solver = newton.solvers.SolverMuJoCo(model)
+
+ # Record initial joint position
+ initial_joint0_pos = state_0.joint_q.numpy()[0]
+ initial_joint1_pos = state_0.joint_q.numpy()[1]
+
+ # Simulate
+ dt = 0.001
+ for _ in range(100):
+ solver.step(state_0, state_1, control, None, dt)
+ state_0, state_1 = state_1, state_0
+
+ # Verify joint moved due to tendon actuation
+ final_joint0_pos = state_0.joint_q.numpy()[0]
+ final_joint1_pos = state_0.joint_q.numpy()[1]
+ self.assertNotAlmostEqual(initial_joint0_pos, final_joint0_pos, places=3)
+ self.assertNotAlmostEqual(initial_joint1_pos, final_joint1_pos, places=3)
+ # Joint should have rotated (positive direction due to gravity)
+ self.assertGreater(final_joint0_pos, initial_joint0_pos)
+ # Joint should have rotated (negative direction due to tendon contraction pulling site2 towards site1)
+ self.assertLess(final_joint1_pos, initial_joint1_pos)
+
+
+if __name__ == "__main__":
+ wp.clear_kernel_cache()
+ unittest.main(verbosity=2)