Getting quaternions from exposed joints

I’m attempting to compile a list of poses, stored as lists of quaternions, from a simple armature that I’ve exported to egg-file. Unfortunately, I don’t seem to be managing to get the quaternions that I expect: I’m getting something out, but they don’t seem to match up with the poses that I modelled.

The code that’s displaying the poses worked previously with poses specified manually (via quat.setHpr), so that shouldn’t be the problem, I think.

I’ve done some searching on the forum, but haven’t found a solution that seems to work for me, thus far. (I’ll confess that I’m pretty tired at the moment, so my apologies if I’ve missed something.)

This is what I’m doing:

        # For reasons not worth going in to, I'm calling my poses "stations".

        #  First, load the egg file as an actor and expose the desired joints.
        #  Based on my reading on the forum, I'm guessing that
        # I want localTransform to be True in order that I get my
        # transforms relative to the joints' parents.
        stationModel = Actor("playerStations", {"stations":"playerStations-stations"})
        shoulder = stationModel.exposeJoint(None, "modelRoot", "shoulder",
                                                               localTransform = True)
        upperArm = stationModel.exposeJoint(None, "modelRoot", "upperArm",
                                                                localTransform = True)
        lowerArm = stationModel.exposeJoint(None, "modelRoot", "lowerArm",
                                                                localTransform = True)
        hand = stationModel.exposeJoint(None, "modelRoot", "hand",
                                                         localTransform = True)
        
         #  Now, build the list. There should be one pose per element of the list
         # mentioned in the "for" loop.
        for i in xrange(len(self.stations[0])):
            # Pose the actor, then force it to  update
            stationModel.pose("stations", i+1)
            stationModel.update(force = True)
            
            #  Now attempt to build the list of orientations for this pose/"station".
            #  I've tried getting the quats relative to render or the
            # preceding joint, andI've tried creating dummy nodes,
            # placing them at the joints and then reading 
            # -those-, all to no avail thus far, as I recall. :/
            quatList = [shoulder.getQuat(), upperArm.getQuat(),
                             lowerArm.getQuat(), hand.getQuat()]
            self.stations[0][i] = quatList

My thanks for any aid given.

I’ve been working with with custom poses using controlJoint rather than exposeJoint, and I’ve been able to get the same quaternions in and out of the joints. I recall reading on the forum somewhere that controlJoint and exposeJoint don’t operate in the same coordinate space. I think possibly exposeJoint operates relative to the parent, while controlJoint places the joint in its own coordinate space. (I may be getting working quats in and out because of how the parenting has been set up, in my implementation. I confess to not knowing enough about all of this to know if that’s the case. :laughing:)

Here are the classes I put together to handle the armature and the poses. Don’t know if these will help you at all. Most of this was derived rather directly from things I found on this forum. Also, apologies for what I’m sure is rather dodgy Python coding on my part. I can attest that these work, but they may not be well-constructed.

Here’s the figure class.

class myFigure(object):
    def __init__(self, figure):
        self.figure = figure
        self.anims = {}
        self.curAnim = None
        self.curAnimName = ""
        self.flatjoints = []
        self.flattened_hierarchy(self.figure.getPartBundle('modelRoot'), indent = "")
        self.parts = {}
        self.get_controlJoints()
        self.has_parts = False
        self.morphs = {}
        self.has_morphs = False
        self.get_origins()
        self.root = self.flatjoints[0][0].getName()
        self.rootchild = self.flatjoints[1][0].getName()

    def load_anim(self, path, name, rate):
        animset = myAnim(path, rate=rate)
        animset.addAnim(name)
        animset.figure = self
        animname = os.path.splitext(name)[0]
        self.anims[animname] = animset
        self.curAnimName = animname
        self.curAnim = self.anims[animname]
        return self.curAnim

    def select_anim(self,animname):
        if animname in self.anims:
            self.curAnim = animname

    def flattened_hierarchy(self, part, parentNode = None, indent = ""):
        if isinstance(part, CharacterJoint):        
            self.flatjoints.append((part,parentNode))
        parentNode = part
        for child in part.getChildren():
            self.flattened_hierarchy(child, parentNode, indent + "  ")

    def get_controlJoints(self):               
        for part, parent in self.flatjoints:
            self.parts[part.getName()] = self.figure.controlJoint(None, 'modelRoot', part.getName())
            self.has_parts = True

    def controlJoints_recurse(self, part, indent = ""):        
        if isinstance(part, CharacterJoint):
            self.parts[part.getName()] = fig.controlJoint(None, 'modelRoot', part.getName())
        for child in part.getChildren():
            self.controlJoints_recurse(child, indent = "")

    def controlJoints_iter(self):
        pass

    def showSkeleton(self, part, parentNode = None, indent = ""):
        # https://discourse.panda3d.org/t/visualise-actor-bones-skeleton/9961/7
        # Adapted from example function by rdb
        if isinstance(part, CharacterJoint):
            np = self.figure.exposeJoint(None, 'modelRoot', part.getName())
            if parentNode and parentNode.getName() != "root":
                lines = LineSegs()
                lines.setThickness(2.0)
                lines.setColor(0, 0, 0)
                lines.moveTo(0, 0, 0)
                lines.drawTo(np.getPos(parentNode))            
                lnp = parentNode.attachNewNode(lines.create())
                lnp.setBin("fixed", 40)
                lnp.setDepthWrite(False)
                lnp.setDepthTest(False)
            parentNode = np
        for child in part.getChildren():
            self.showSkeleton(child, parentNode, indent + "  ")

    def zeroPose(self):
        parts = self.parts        
        for name in parts.keys():            
            joint = parts[name]                
            pos = joint.getPos()                
            quat = (1.0, 0.0, 0.0, 0.0)
            scale = (10., 1.0, 1.0)
            joint.setPosQuatScale(pos,quat,scale)

    def get_origins(self):
        self.origins = {}
        parts = self.parts        
        for name in parts.keys():            
            joint = parts[name]                
            pos = joint.getPos()
            self.origins[name] = (pos[0], pos[1], pos[2])  

The animation class.

class myAnim(object):
    def __init__(self, path, rate=1.0, fps=30):
        self.frames = []
        self.path = path.replace("/",os.sep)
        self.numframes = 0
        self.poses = []
        self.backwards = False
        if rate != abs(rate):
            self.backwards = True
        if rate == 0.0:
            self.rate = rate
        else:
            self.rate = 1/abs(rate)
        self.trueRate = rate # Holds original rate argument
        self.label = None
        self.posenum = -1
        self.figure = None
        self.name = ""
        self.intensity = 1.0        
        self.fps = 1.0/fps
        self.trueFps = fps # Holds original fps argument
        self.scaling_factor = 10.0
        self.time = 0.0
        self.numreps = 0
        self.frequency = 0 # Holds frequency of task call
        self.fromFrame = 0 # Start frame for play range
        self.toFrame = self.numframes-1 # End frame for play range

    def runAnim(self, task):
        self.setPose(self.posenum)
        #self.label["text"] = "%s - %s" %(self.posenum, self.poses[self.posenum])
        self.textObject.setText("%s - %s - %s" %(round(self.frequency,6), self.posenum, self.poses[self.posenum]))
        self.posenum += 1
        if self.posenum >= self.numframes:
            self.posenum = 0
            if self.numreps != -1:
                self.numreps -= 1
        if self.numreps == 0:
            return task.done
        else:
            return Task.again

    def reverseAnim(self, task):
        self.setPose(self.posenum)
        #self.label["text"] = "%s - %s" %(self.posenum, self.poses[self.posenum])
        self.posenum -= 1
        if self.posenum < 0:
            self.posenum = self.numframes-1
            if self.numreps != -1:
                self.numreps -= 1
        if self.numreps == 0:
            return task.done
        else:
            return Task.again

    def set_timerLabel(self):
        self.label = DirectLabel()
        self.label.reparentTo(render)
        self.label.setPos(0, -5.0, 1.0)

    def addAnim(self,path):
        fullpath = os.path.join(self.path,path)
        self.poses = [i for i in os.listdir(fullpath) if os.path.splitext(i)[1] == ".bPose"]    
        for pose in self.poses:
            anim = self.readPose(os.path.join(fullpath,pose))
            self.frames.append(anim)
        self.numframes = len(self.frames)
        self.name = path

    def readPose(self,pose):
        anim = {}
        f = open(pose)
        l = f.readlines()
        f.close()
        for line in l:
            name, t1, t2, t3, q1, q2, q3, q4, s1, s2, s3 = line.split(",")            
            q = Quat(float(q1), float(q2), float(q3), float(q4))
            q.normalize()
            hpr = q.getHpr(CSYupRight)
            q.setHpr(hpr)
            quat = (q.getR(), q.getI(), q.getJ(), q.getK())
            pos = (float(t1), float(t2), float(t3))      
            scale = (float(s1), float(s2), float(s3))
            anim[name] = [pos, quat, scale]
        return anim

    def setPose(self,frame):        
        parts = self.figure.parts
        poses = self.frames[frame]
        origins = self.figure.origins
        scaling = self.scaling_factor
        if self.intensity != 0.0:
            s_i = 1/self.intensity
            zero = False
        else:
            s_i = self.intensity
            zero = True
        for name in parts.keys():
            if name in poses.keys():                
                joint = parts[name]
                #curpos = joint.getPos()
                pose = poses[name]
                pos = list(pose[0])
                orig = origins[name]
                for i in range(3):
                    pos[i] *= scaling
                pos = (pos[0]+orig[0], (-pos[1])+orig[1], pos[2]+orig[2])
                if zero:
                    quat = (1.0, 0.0, 0.0, 0.0)
                else:
                    quat = pose[1]                    
                    quat = (quat[0]*s_i, quat[1], quat[2], quat[3])                   
                scale = pose[2]
                joint.setPosQuatScale(pos,quat,scale)

    def getFrame(self):
        return self.posenum

    def getNextFrame(self):
        if self.posenum == self.numframes - 1:
            return 0
        else:
            return self.posenum + 1

    def getNumFrames(self):
        return self.numframes

    def getFps(self):
        return self.TrueFps

    def getPlayRate(self):
        return self.Truerate

    def setPlayRate(self, rate):
        if rate != abs(rate):
            self.reverse = True
        if rate == 0.0:
            self.rate = abs(rate)
        else:
            self.rate = 1/abs(rate)
        self.trueRate = rate

    def isPlaying(self):
        if taskMgr.hasTaskNamed('%s_loop'%(self.name)):
            return True
        return False

    def pose(self, posenum, intensity=1.0):
        self.intensity = intensity
        self.setPose(posenum)
        self.intensity = 1.0

    def reverse(self):
        self.backwards = 1 - self.backwards
        # Need support to reverse animation in-place; need start and stop frames

    def loop(self, intensity=1.0, reps=-1, fromFrame=0, toFrame=29):
        self.intensity = intensity
        self.numreps = reps
        #self.set_timerLabel()        
        self.frequency = self.fps * self.rate
        self.textObject = OnscreenText('%s' %(round(self.frequency,8)), pos = (-0.5, 0.95), scale = 0.07)
        if self.backwards:
            taskMgr.doMethodLater(self.fps * self.rate, self.reverseAnim, '%s_loop'%(self.name))
        else:
            taskMgr.doMethodLater(self.fps * self.rate, self.runAnim, '%s_loop'%(self.name))

    def play(self, intensity=1.0):
        """Run through animation once."""
        self.loop(intensity=intensity, reps=1)

    def stop(self):
        taskMgr.remove('%s_loop'%(self.name))
        self.intensity = 1.0

    def blend(self, other, t):
        """
        Argument t is blending factor.  Animation 'self' receives t influence on result.
        Animation 'other' has 1/t influence on result. 
        """
        new_anim = self.copy()
        new_anim.mixAnim(other, t=t)
        self.figure.anims[new_anim.name] = new_anim
        return new_anim

    def mixAnim(self, other, t=0.5):
        #frames[num][name] = pose
        s_poses = self.frames
        o_poses = other.frames
        o_len = len(o_poses)-1
        for num in range(self.numframes):
            s_pose = s_poses[num]
            if num <= o_len:
                o_pose = o_poses[num]
                for name in s_pose.keys():                                        
                    s_quat = Quat(s_pose[name][1])
                    o_quat = Quat(o_pose[name][1])
                    quat = self.slerp(t, s_quat, o_quat)
                    self.frames[num][name][1] = quat
        self.name = "%s_blend" %(self.name)

    def copy(self):
        """Load a new copy of current animation"""
        new_anim = myAnim(self.path, rate=self.trueRate, fps=self.trueFps)        
        new_anim.addAnim(self.name)
        new_anim.figure = self.figure
        return new_anim

    def slerp(self, t, q0, q1):
        # http://nullege.com/codes/search/quat.slerp#
        epsilon = 1.19209290e-07        
        dot = q0[0]*q1[0] + q0[1]*q1[1] + q0[2]*q1[2] + q0[3]*q1[3]        
        dot = min(1.0,max(-1.0,dot)) # Avoid math domain errors due to float inaccuracy
        #dot = round(dot,6)
        o = math.acos(dot)
        so = math.sin(o)
        if abs(so) <= epsilon:
            return q0
        a = math.sin(o*(1.0-t))/so
        b = math.sin(o*t)/so
        return q0*a + q1*b

Example of use:

Just import the figure.

rate = 1.0
self.figure = Actor("models/AntoniaLo/AntoniaA_poser2egg_fig.egg")

Then set up the joints and load a pose. Example pose to follow.

if use_anim == -3:            
            self.fig = myFigure(self.figure)
            #anim1 = self.fig.load_anim("models/AntoniaLo/blend_poses/", "running", rate)
            #anim2 = self.fig.load_anim("models/AntoniaLo/blend_poses/", "power_walk", rate)
            #self.fig.curAnim.loop()
            #self.fig.anims["walk"].loop()
            #self.fig.anims["running"].loop()
            #self.fig.anims["walk"].pose(12)
            #anim3 = anim1.blend(anim2,0.35)
            #anim3.loop(0.8)
            anim1 = self.fig.load_anim("models/AntoniaLo/blend_poses/", "ballet_12_bvh", rate)
            anim1.loop()
            #anim1.pose(122)
            if rdb_showbones:      
                self.fig.showSkeleton(self.figure.getPartBundle('modelRoot'), None)

Pose format. This is a custom Blender pose format developed for the old Blender Library system, back in 2005 or 2006. It’s just joint name, xyz translation, quaternion as wxyz (rijk, if you prefer), xyz scale. This pose wouldn’t work with your armatures, but the format will work with the code I’ve posted above. Poses are saved with a .bPose extension, for use with the code posted above. Note that each individual .bPose contains a separate frame. For animation, the code I posted expects a series of one-frame .bPose files, all gathered in a single folder. The folder name is passed to the code, to load the animation. That folder name is then treated as the animation name. So if I have a 30-frame loop of .bPose files in a folder named “running”, the code loads that as a looped running animation named “running”.

I’ve been meaning to refine the pose format, to put all frames from an animation into one file, but I’ve been distracted by trying to understand geometry and texture handling in Panda. :laughing: :blush:

After all of this, my apologies if I have completely missed your point or this has been otherwise unhelpful. :blush:

Body,0.000000,0.000000,0.000000,0.950000,0.000000,0.000000,0.000000,1.0,1.0,1.0
GoalCenterOfMass_3,0.0,0.0,0.0,1.000000,0.000000,0.000000,0.000000,1.0,1.0,1.0
CenterOfMass_3,0.0,0.0,0.0,1.000000,0.000000,0.000000,0.000000,1.0,1.0,1.0
Waist,0.000002,0.007213,-0.016105,0.996488,0.077984,-0.002059,-0.030417,1.0,1.0,1.0
Hip,0.0,0.0,0.0,1.000000,0.000000,0.000000,0.000000,1.0,1.0,1.0
Abdomen,0.0,0.0,0.0,0.997639,0.020702,-0.058873,0.028659,1.0,1.0,1.0
Chest,0.0,0.0,0.0,0.996529,0.028655,-0.050944,0.059275,1.0,1.0,1.0
Neck,0.0,0.0,0.0,1.000000,-0.000256,0.000027,-0.000081,1.0,1.0,1.0
Head,0.0,0.0,0.0,0.993141,-0.079474,0.085562,0.005775,1.0,1.0,1.0
LeftEye,0.0,0.0,0.0,1.000000,0.000000,-0.000085,0.000000,1.0,1.0,1.0
RightEye,0.0,0.0,0.0,1.000000,0.000000,-0.000085,0.000000,1.0,1.0,1.0
JawLower,0.0,0.0,0.0,1.000000,-0.000085,0.000000,0.000000,1.0,1.0,1.0
Tongue1,0.0,0.0,0.0,1.000000,0.000000,0.000000,0.000000,1.0,1.0,1.0
Tongue2,0.0,0.0,0.0,1.000000,-0.000085,0.000000,0.000000,1.0,1.0,1.0
JawUpper,0.0,0.0,0.0,1.000000,-0.000085,0.000000,0.000000,1.0,1.0,1.0
Right_Collar,0.0,0.0,0.0,0.999313,0.018073,-0.028410,-0.015509,1.0,1.0,1.0
Right_Shoulder,0.0,0.0,0.0,0.828784,0.223885,-0.008104,0.512764,1.0,1.0,1.0
Right_Forearm,0.0,0.0,0.0,0.618233,0.428715,0.023645,-0.658356,1.0,1.0,1.0
Right_Hand,0.0,0.0,0.0,0.979456,-0.065492,0.190432,-0.010577,1.0,1.0,1.0
rThumb1,0.0,0.0,0.0,0.955628,0.257906,-0.114061,-0.085147,1.0,1.0,1.0
rThumb2,0.0,0.0,0.0,0.979210,0.106716,0.025313,0.170645,1.0,1.0,1.0
rThumb3,0.0,0.0,0.0,0.901933,-0.404862,-0.095550,-0.116073,1.0,1.0,1.0
rMid1,0.0,0.0,0.0,0.818606,0.283779,0.390882,0.310748,1.0,1.0,1.0
rMid2,0.0,0.0,0.0,0.694376,0.387713,0.451690,0.404347,1.0,1.0,1.0
rMid3,0.0,0.0,0.0,0.986883,0.062583,0.039930,0.143353,1.0,1.0,1.0
rRing1,0.0,0.0,0.0,0.758369,0.280093,0.459146,0.368252,1.0,1.0,1.0
rRing2,0.0,0.0,0.0,0.765693,0.383944,0.438737,0.271681,1.0,1.0,1.0
rRing3,0.0,0.0,0.0,0.913012,0.340736,0.212357,0.072199,1.0,1.0,1.0
rPinky1,0.0,0.0,0.0,0.797200,0.056677,0.467808,0.377379,1.0,1.0,1.0
rPinky2,0.0,0.0,0.0,0.738413,0.272692,0.464228,0.406050,1.0,1.0,1.0
rPinky3,0.0,0.0,0.0,0.925126,0.119166,0.268928,0.240039,1.0,1.0,1.0
rIndex1,0.0,0.0,0.0,0.940681,0.236803,0.125047,0.208340,1.0,1.0,1.0
rIndex2,0.0,0.0,0.0,0.669261,0.486205,0.397976,0.396622,1.0,1.0,1.0
rIndex3,0.0,0.0,0.0,0.721438,0.460285,0.340519,0.389502,1.0,1.0,1.0
Left_Collar,0.0,0.0,0.0,0.998842,-0.010813,-0.044120,0.015871,1.0,1.0,1.0
Left_Shoulder,0.0,0.0,0.0,0.783618,0.132493,-0.222832,-0.564566,1.0,1.0,1.0
Left_Forearm,0.0,0.0,0.0,0.545082,0.475442,0.056258,0.688241,1.0,1.0,1.0
Left_Hand,0.0,0.0,0.0,0.975964,-0.048011,-0.212551,0.003221,1.0,1.0,1.0
lIndex1,0.0,0.0,0.0,0.940586,0.236902,-0.125306,-0.208501,1.0,1.0,1.0
lIndex2,0.0,0.0,0.0,0.669034,0.486240,-0.398178,-0.396760,1.0,1.0,1.0
lIndex3,0.0,0.0,0.0,0.721225,0.460332,-0.340730,-0.389655,1.0,1.0,1.0
lMid1,0.0,0.0,0.0,0.818423,0.283807,-0.391121,-0.310904,1.0,1.0,1.0
lMid2,0.0,0.0,0.0,0.694153,0.387723,-0.451900,-0.404487,1.0,1.0,1.0
lMid3,0.0,0.0,0.0,0.986843,0.062668,-0.040205,-0.143519,1.0,1.0,1.0
lRing1,0.0,0.0,0.0,0.758159,0.280106,-0.459370,-0.368394,1.0,1.0,1.0
lRing2,0.0,0.0,0.0,0.765510,0.383953,-0.438968,-0.271813,1.0,1.0,1.0
lRing3,0.0,0.0,0.0,0.912926,0.340775,-0.212619,-0.072325,1.0,1.0,1.0
lThumb1,0.0,0.0,0.0,0.955642,0.258019,0.113802,0.084993,1.0,1.0,1.0
lThumb2,0.0,0.0,0.0,0.979141,0.106917,-0.025442,-0.170892,1.0,1.0,1.0
lThumb3,0.0,0.0,0.0,0.902058,-0.404660,0.095438,0.115899,1.0,1.0,1.0
lPinky1,0.0,0.0,0.0,0.796995,0.056674,-0.468040,-0.377525,1.0,1.0,1.0
lPinky2,0.0,0.0,0.0,0.738205,0.272692,-0.464453,-0.406171,1.0,1.0,1.0
lPinky3,0.0,0.0,0.0,0.925004,0.119185,-0.269198,-0.240197,1.0,1.0,1.0
RightThigh,0.0,0.0,0.0,0.963473,-0.235059,-0.021691,0.126476,1.0,1.0,1.0
Right_Shin,0.0,0.0,0.0,0.994179,0.099756,-0.039697,-0.008980,1.0,1.0,1.0
RightFoot,0.0,0.0,0.0,0.998558,0.046140,0.014565,0.023272,1.0,1.0,1.0
RightInstep,0.0,0.0,0.0,1.000000,-0.000082,-0.000087,-0.000095,1.0,1.0,1.0
RightToe,0.0,0.0,0.0,0.994249,-0.091655,0.001208,0.055380,1.0,1.0,1.0
RightBigToe1,0.0,0.0,0.0,1.000000,-0.000082,-0.000086,-0.000102,1.0,1.0,1.0
RightBigToe2,0.0,0.0,0.0,1.000000,-0.000083,-0.000084,-0.000108,1.0,1.0,1.0
LeftThigh,0.0,0.0,0.0,0.999681,-0.019284,0.008699,-0.013831,1.0,1.0,1.0
Left_Shin,0.0,0.0,0.0,0.720212,0.688490,0.084562,-0.011197,1.0,1.0,1.0
LeftFoot,0.0,0.0,0.0,0.966474,0.237646,0.058888,-0.077365,1.0,1.0,1.0
LeftInstep,0.0,0.0,0.0,1.000000,-0.000087,-0.000083,-0.000076,1.0,1.0,1.0
LeftToe,0.0,0.0,0.0,0.996420,-0.080523,0.010595,0.023462,1.0,1.0,1.0
LeftBigToe1,0.0,0.0,0.0,1.000000,-0.000085,-0.000084,-0.000068,1.0,1.0,1.0
LeftBigToe2,0.0,0.0,0.0,1.000000,-0.000081,-0.000086,-0.000062,1.0,1.0,1.0

Thank you very much for that. I’m not sure that what you’ve posted there is quite what I’m looking for, but it has helped by pointing out some avenues for investigation that seem to have allowed me to make some progress. :slight_smile:

I discovered a few things that I was doing incorrectly, I believe:

  • I had been using the “pose” method under the impression that its parameter specified a keyframe, not just a frame, which in retrospect was silly. Since my keyframes–my poses, essentially–are spaced about ten frames apart, a simple multiplier solved that issue.
  • I had been clipping off the last keyframe from my list by ending the animation at that frame; this was a minor issue, however (and easily rectified by extending the animation).

I also discovered this post, which looks as thought it’s addressing a similar idea. While I had been including “localTransform = True” in my calls to exposeJoint, I hadn’t known what the first parameter was, and so had been leaving that as “None”.

However, having read that post, I’m not sure of what nodes to pass into that first parameter. At the moment, for each “exposeJoint” after the first, I’m passing in the NodePath returned by the previous “exposeJoint” (since I’m modelling a single limb, and thus my joints form a single chain), but I suspect that this isn’t correct.

I’ve looked at the result of “getPartBundle”, as suggested in the post, but don’t see a way to get appropriate NodePaths out of that: “getPartBundle” returns a “CharacterJointBundle”; it’s child (I have only one at the moment, it seems) is a “PartGroup”, and the child of that is a “CharacterJoint”. Looking at the API covering those classes, I don’t see a likely-looking way to get appropriate NodePaths out of them.

Interestingly, if in my calls to “exposeJoint” I include “localTransform = True” but leave the first parameter as “None”, I get poses that look almost like the ones that I modelled, but still off.

So, I’m still not quite there, but a little closer, I feel…

Ah, I’ve done it, I believe! :slight_smile:

There were a few problems that called for correction:

  • The NodePaths to which I was applying the quaternions extracted from “exposeJoint” were being created treating the x-axis as the “zero-direction”–that is, the direction in which they pointed when they had a zero quaternion. Changing this such that the “zero-direction” pointed along the y-axis helped a lot. (In retrospect this makes a lot of sense.)
  • My armature had been designed with its base joint pointing along the x-axis, which produced a 90-degree rotation of my entire set of joints when I retrieved them via “exposeJoint”; simply moving the base joint such that it pointed along the y-axis fixed this, I believe.
  • I had some discrepancies between the sizes of my Actor-joints and the sizes (or rather offsets) of the corresponding NodePaths.

As to the parameters to “exposeJoint”, I found in the end that, after the above fixes were in place, passing in “localTransform = True” and leaving the first parameter as “None” seemed to give me the the orientations that I was looking for.