Source code for isaaclab_newton.envs.mdp.actions.newton_ik_actions

# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING

import torch
import warp as wp
from newton import JointType
from newton import Model as NewtonModel
from newton.selection import ArticulationView

import isaaclab.sim as sim_utils
import isaaclab.utils.string as string_utils
from isaaclab import cloner
from isaaclab.assets.articulation.base_articulation import BaseArticulation
from isaaclab.managers.action_manager import ActionTerm

from isaaclab_newton.ik.newton_ik_objectives_cfg import NewtonIKPoseObjectiveCfg
from isaaclab_newton.physics import NewtonManager

if TYPE_CHECKING:
    from isaaclab.envs import ManagerBasedEnv
    from isaaclab.envs.utils.io_descriptors import GenericActionIODescriptor

    from isaaclab_newton.ik.newton_ik_objectives import NewtonIKPoseObjective

    from .newton_ik_actions_cfg import NewtonInverseKinematicsActionCfg


logger = logging.getLogger(__name__)


@wp.kernel(enable_backward=False)
def _ik_world_target_kernel(
    body_pos_w: wp.array2d(dtype=wp.vec3f),
    body_quat_w: wp.array2d(dtype=wp.quatf),
    root_pos_w: wp.array(dtype=wp.vec3f),
    root_quat_w: wp.array(dtype=wp.quatf),
    body_idx: int,
    offset: wp.transformf,
    action: wp.array2d(dtype=wp.float32),
    action_offset: int,
    scale: wp.array(dtype=wp.float32),
    command_code: int,
    use_relative: int,
    out_pos: wp.array(dtype=wp.vec3f),
    out_rot: wp.array(dtype=wp.vec4f),
):
    """Map one pose objective's action slice to a prototype-world target pose.

    Mirrors ``subtract_frame_transforms`` -> body offset -> command (position /
    relative-pose / absolute-pose) -> ``combine_frame_transforms`` against the
    env-0 root, writing the target straight into the objective's Warp arrays.
    """
    i = wp.tid()
    # End-effector (offset) pose in the env's root frame.
    root_t = wp.transformf(root_pos_w[i], root_quat_w[i])
    ee_t = wp.transform_multiply(
        wp.transform_inverse(root_t), wp.transformf(body_pos_w[i, body_idx], body_quat_w[i, body_idx])
    )
    ee_t = wp.transform_multiply(ee_t, offset)
    ee_pos = wp.transform_get_translation(ee_t)
    ee_rot = wp.transform_get_rotation(ee_t)

    target_pos = ee_pos
    target_rot = ee_rot
    if command_code == 0:  # COMMAND_POSITION
        disp = wp.vec3f(
            action[i, action_offset + 0] * scale[0],
            action[i, action_offset + 1] * scale[1],
            action[i, action_offset + 2] * scale[2],
        )
        target_pos = ee_pos + disp if use_relative == 1 else disp
    else:
        if use_relative == 1:
            target_pos = ee_pos + wp.vec3f(
                action[i, action_offset + 0] * scale[0],
                action[i, action_offset + 1] * scale[1],
                action[i, action_offset + 2] * scale[2],
            )
            rot_vec = wp.vec3f(
                action[i, action_offset + 3] * scale[3],
                action[i, action_offset + 4] * scale[4],
                action[i, action_offset + 5] * scale[5],
            )
            angle = wp.length(rot_vec)
            delta_rot = wp.quat_identity()
            if angle > 1.0e-6:
                delta_rot = wp.quat_from_axis_angle(rot_vec / angle, angle)
            target_rot = delta_rot * ee_rot
        else:
            target_pos = wp.vec3f(
                action[i, action_offset + 0] * scale[0],
                action[i, action_offset + 1] * scale[1],
                action[i, action_offset + 2] * scale[2],
            )
            target_rot = wp.quatf(
                action[i, action_offset + 3] * scale[3],
                action[i, action_offset + 4] * scale[4],
                action[i, action_offset + 5] * scale[5],
                action[i, action_offset + 6] * scale[6],
            )

    # Broadcast against the env-0 prototype root (all roots identical, validated).
    world_t = wp.transform_multiply(wp.transformf(root_pos_w[0], root_quat_w[0]), wp.transformf(target_pos, target_rot))
    out_pos[i] = wp.transform_get_translation(world_t)
    q = wp.transform_get_rotation(world_t)
    out_rot[i] = wp.vec4f(q[0], q[1], q[2], q[3])


@wp.kernel(enable_backward=False)
def _ik_seed_scatter_kernel(
    joint_pos: wp.array2d(dtype=wp.float32),
    coord_ids: wp.array(dtype=wp.int32),
    seed: wp.array2d(dtype=wp.float32),
):
    """Overwrite the seed's actuated coordinates with the live joint positions."""
    i, j = wp.tid()
    seed[i, coord_ids[j]] = joint_pos[i, j]


@wp.kernel(enable_backward=False)
def _ik_gather_kernel(
    solved: wp.array2d(dtype=wp.float32),
    coord_ids: wp.array(dtype=wp.int32),
    out: wp.array2d(dtype=wp.float32),
):
    """Gather the controlled coordinates from the full solved joint vector."""
    i, k = wp.tid()
    out[i, k] = solved[i, coord_ids[k]]


@dataclass
class _PoseDriver:
    """Per-pose-objective binding: the live body to read and its action slice offset."""

    body_idx: int
    action_offset: int
    objective: NewtonIKPoseObjective


[docs] class NewtonInverseKinematicsAction(ActionTerm): """Newton inverse-kinematics action term. Solves IK as a single list of objectives on the cloner's single-env Newton prototype model, then maps the actuated joint coordinates back to the live batched articulation. Each pose objective drives one end-effector body (one is single-body IK, several are multi-body); constraint objectives add no action dimensions. The per-step target computation, seed assembly, solve and gather run entirely in Warp -- Torch appears only as the policy action at the boundary, viewed zero-copy into Warp. Fixed-base articulations only. """ cfg: NewtonInverseKinematicsActionCfg _asset: BaseArticulation
[docs] def __init__(self, cfg: NewtonInverseKinematicsActionCfg, env: ManagerBasedEnv): super().__init__(cfg, env) if not self._asset.is_fixed_base: raise ValueError("NewtonInverseKinematicsAction currently supports fixed-base articulations only.") joint_ids, self._joint_names = self._asset.find_joints(self.cfg.joint_names, as_proxy=True) self._joint_ids = joint_ids.warp pose_cfgs = [obj for obj in self.cfg.objectives if isinstance(obj, NewtonIKPoseObjectiveCfg)] if not pose_cfgs: raise ValueError("NewtonInverseKinematicsAction requires at least one pose objective.") # Resolve the controlled asset to its clone-plan source and finalize the # single-env prototype builder the cloner already retained -- the same # source resolution other Newton consumers use, no bespoke registry. plan = sim_utils.SimulationContext.instance().get_clone_plan() source_path, _, asset_suffix = cloner.query.path_to_source(plan, self._asset.cfg.prim_path) # The proto builder is keyed by the bare clone source; the articulation # lives at the asset suffix below it (e.g. ".../env_0" + "/Robot"). self._source_path = source_path + asset_suffix prototype_model = NewtonManager._cl_protos[source_path].finalize(device=NewtonManager.get_model().device) prototype_view = ArticulationView( prototype_model, self._source_path, verbose=False, exclude_joint_types=[JointType.FREE, JointType.FIXED], ) coord_ids = self._resolve_prototype_joint_coord_ids(prototype_view, self._asset.joint_names) controlled_ids = self._resolve_prototype_joint_coord_ids(prototype_view, self._joint_names) # The solver resolves each pose objective's body via the prototype view. self._ik_solver = self.cfg.controller.class_type( self.cfg.controller, model=prototype_model, num_envs=self.num_envs, device=self.device, objectives=self.cfg.objectives, link_resolver=lambda body_name: self._resolve_prototype_link_index(prototype_view, body_name), ) # Bind each pose objective to the live body it reads and its action slice. self._drivers: list[_PoseDriver] = [] offset = 0 for pose_cfg in pose_cfgs: name = pose_cfg.name if pose_cfg.name is not None else pose_cfg.body_name objective = self._ik_solver.objectives_by_name[name] body_idx = self._resolve_isaac_body_index(pose_cfg.body_name) self._drivers.append(_PoseDriver(body_idx, offset, objective)) offset += objective.action_dim self._action_dim = offset self._raw_actions = torch.zeros(self.num_envs, self._action_dim, device=self.device) self._processed_actions = torch.zeros_like(self._raw_actions) # Warp scratch for the seed -> solve -> gather pipeline. num_coords = prototype_model.joint_coord_count default_seed = wp.to_torch(prototype_model.joint_q).to(device=self.device, dtype=torch.float32) self._default_seed = wp.from_torch(default_seed.unsqueeze(0).repeat(self.num_envs, 1).contiguous()) self._seed = wp.zeros((self.num_envs, num_coords), dtype=wp.float32, device=self.device) self._joint_pos_des = wp.zeros((self.num_envs, len(self._joint_ids)), dtype=wp.float32, device=self.device) self._coord_ids = wp.from_torch(coord_ids.to(torch.int32).contiguous()) self._controlled_ids = wp.from_torch(controlled_ids.to(torch.int32).contiguous()) self._clip = None if self.cfg.clip is not None: self._clip = torch.tensor([[-float("inf"), float("inf")]], device=self.device).repeat( self.num_envs, self._action_dim, 1 ) action_names = self._action_coordinate_names() index_list, _, value_list = string_utils.resolve_matching_names_values(self.cfg.clip, action_names) self._clip[:, index_list] = torch.tensor(value_list, device=self.device) logger.info( "Resolved Newton IK action joints %s [%s] and bodies %s.", self._joint_names, self._joint_ids, [(d.objective.name, d.body_idx) for d in self._drivers], )
@property def action_dim(self) -> int: return self._action_dim @property def raw_actions(self) -> torch.Tensor: return self._raw_actions @property def processed_actions(self) -> torch.Tensor: return self._processed_actions @property def IO_descriptor(self) -> GenericActionIODescriptor: super().IO_descriptor self._IO_descriptor.shape = (self._action_dim,) self._IO_descriptor.dtype = str(self.raw_actions.dtype) self._IO_descriptor.action_type = "NewtonInverseKinematicsAction" self._IO_descriptor.joint_names = self._joint_names self._IO_descriptor.clip = self.cfg.clip self._IO_descriptor.extras["controller_cfg"] = self.cfg.controller.__dict__ self._IO_descriptor.extras["objective_names"] = [d.objective.name for d in self._drivers] self._IO_descriptor.extras["coordinate_names"] = self._action_coordinate_names() return self._IO_descriptor def process_actions(self, actions: torch.Tensor) -> None: self._raw_actions[:] = actions self._processed_actions[:] = self._raw_actions if self._clip is not None: self._processed_actions = torch.clamp( self._processed_actions, min=self._clip[:, :, 0], max=self._clip[:, :, 1] ) # Each pose objective maps its action slice to a prototype-world target, # written straight into its Warp target arrays. self._validate_matching_root_orientations() action_wp = wp.from_torch(self._processed_actions.contiguous(), dtype=wp.float32) body_pos_w = self._asset.data.body_pos_w.warp body_quat_w = self._asset.data.body_quat_w.warp root_pos_w = self._asset.data.root_pos_w.warp root_quat_w = self._asset.data.root_quat_w.warp for driver in self._drivers: obj = driver.objective wp.launch( _ik_world_target_kernel, dim=self.num_envs, inputs=[ body_pos_w, body_quat_w, root_pos_w, root_quat_w, driver.body_idx, obj.offset, action_wp, driver.action_offset, obj.scale, obj.command_code, obj.use_relative, obj.position_objective.target_positions, obj.rotation_objective.target_rotations, ], device=self.device, ) def apply_actions(self) -> None: # Seed the solver from the live joint positions on top of the prototype # default, solve, and write the controlled coordinates back -- all in Warp. wp.copy(self._seed, self._default_seed) wp.launch( _ik_seed_scatter_kernel, dim=(self.num_envs, len(self._asset.joint_names)), inputs=[self._asset.data.joint_pos.warp, self._coord_ids, self._seed], device=self.device, ) solved = self._ik_solver.solve(self._seed) wp.launch( _ik_gather_kernel, dim=(self.num_envs, len(self._joint_ids)), inputs=[solved, self._controlled_ids, self._joint_pos_des], device=self.device, ) self._asset.set_joint_position_target_index(target=self._joint_pos_des, joint_ids=self._joint_ids) def reset(self, env_ids: Sequence[int] | None = None) -> None: env_ids = slice(None) if env_ids is None else env_ids self._raw_actions[env_ids] = 0.0 def _validate_matching_root_orientations(self) -> None: """Guard the prototype-frame IK assumption for replicated fixed-base roots.""" root_quat_w = self._asset.data.root_quat_w.torch if root_quat_w.shape[0] <= 1: return # q and -q represent the same orientation, so compare absolute dot products. same_orientation = torch.abs(torch.sum(root_quat_w * root_quat_w[0:1], dim=-1)) > 1.0 - 1e-5 if not torch.all(same_orientation): bad_env_ids = torch.nonzero(~same_orientation, as_tuple=False).flatten().tolist() raise RuntimeError( "NewtonInverseKinematicsAction solves against the env 0 prototype root orientation, but " f"root orientations differ in env ids {bad_env_ids}. Use identical fixed-base root orientations " "for this action." ) def _resolve_isaac_body_index(self, body_name: str) -> int: body_ids, body_names = self._asset.find_bodies(body_name) if len(body_ids) != 1: raise ValueError( f"Expected one match for Newton IK body_name={body_name}. Found {len(body_ids)}: {body_names}." ) return body_ids[0] def _resolve_prototype_joint_coord_ids( self, prototype_view: ArticulationView, joint_names: Sequence[str] ) -> torch.Tensor: layout = prototype_view.frequency_layouts[NewtonModel.AttributeFrequency.JOINT_COORD] selected_indices = self._layout_indices(layout) coord_indices_by_name = { name: layout.offset + selected_indices[index] for index, name in enumerate(prototype_view.joint_coord_names) } coord_ids = [coord_indices_by_name[name] for name in joint_names] return torch.tensor(coord_ids, device=self.device, dtype=torch.long) def _resolve_prototype_link_index(self, prototype_view: ArticulationView, body_name: str) -> int: layout = prototype_view.frequency_layouts[NewtonModel.AttributeFrequency.BODY] selected_indices = self._layout_indices(layout) local_link_index = prototype_view.link_names.index(body_name) return layout.offset + selected_indices[local_link_index] @staticmethod def _layout_indices(layout) -> list[int]: if layout.slice is not None: return list(range(layout.slice.start, layout.slice.stop)) return [int(index) for index in layout.indices.numpy().tolist()] def _action_coordinate_names(self) -> list[str]: names: list[str] = [] for driver in self._drivers: names.extend(f"{driver.objective.name}/{coord}" for coord in driver.objective.command_coordinate_names()) return names