[Maya] PyMEL - Placing a NURBS shape on a face

# Curve is highlighted from the beginning. It is placed on the XZ-plane.
# Face component is already assigned to "face" -var.

curve = pm.ls(selection=True)[0]
pm.select(face)

# Get face center
faceBox = pm.polyEvaluate(boundingBoxComponent=True)
centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])

# Create vectors and calc rotation
vectorA = face.getNormal() # Normal
vectorB = [0.0, 1.0, 0.0] # Y+
vectorAngle = pm.angleBetween(euler=True, vector1=vectorA, vector2=vectorB)

# Move and rotate curve
pm.xform(curve, absolute=False, translation=[centerX, centerY, centerZ], worldSpace=True)
pm.xform(curve, relative=True, rotation=[vectorAngle[0], vectorAngle[1], vectorAngle[2]])
pm.select(curve)

Im trying to place a NURBS shape (circle, square, or other) onto a polygon face.
The curve is placed correctly but the rotation is wrong. What have I missed?

Transforming an object to an orientation isn’t just applying an ‘angle’ or angleBetween as orientation.

Also note that you’re moving the curve with a relative rotation even though how you’re calculating it doesn’t use any information from the curve’s orientation. So, re-applying the command after each other will therefore result in a new orientation each time even if you would have done a ‘more correct’ implementation.

Here’s something that should work:

import pymel.core as pm

def orientationMatrix(aim, up, center=None):
    """
        Calculate a PyMel Matrix based on aim axis, up axis and potentially
        a center point (for translation offset).

        Using Gram schmidt process for orthonormalization
    """
    
    if center is None:
        center = pm.datatypes.Vector()

    # Create vectors and calc rotation
    # We don't do any prenormalization as we assume aim and up axis are normalized
    # So assuming for now that: getNormal() and the upVector we provided is normalized.
    tangent = (aim ^ up).normal()
    up = (tangent ^ aim).normal()
    
    # Set up the matrix
    mat = pm.datatypes.Matrix([aim.x, aim.y, aim.z, 0,
                               up.x, up.y, up.z, 0,
                               tangent.x, tangent.y, tangent.z, 0,
                               center.x,  center.y,  center.z,  1])
    return mat

# Testing
sphere = pm.polySphere(sx=5, sy=5)[0]
for face in sphere.faces:
    curve = pm.curve(p=([0, 0, 0], [5, 0, 0]), degree=1)

    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    up = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+

    mat = orientationMatrix(aim, up, center)
    pm.xform(curve, absolute=True, matrix=mat)

Note that Pymel is a bit slow. I’ve especially found the Matrix class a bit slow (aside from looping over meshes/faces which many have found to be slow).
For speed I would recommend the Python API 2.0 (it’s even easier than the older python api)

Thank you. I tested your code and it works as it is.
However, I am interested in placing a NURBS shape such as a circle or a square onto a face, and for that it appears that the rotation is a little off.

Try replacing
curve = pm.curve(p=([0, 0, 0], [5, 0, 0]), degree=1)
…with…
pm.runtime.CreateNURBSCircle()
curve = pm.ls(sl=True)[0]

The circles are placed correctly but the final rotation position is perpendicular to the face “plane” instead of being aligned to the plane.

And I noticed something odd as well. When I go with a cube instead of a sphere and NURBS circles instead of lines/(curves), two of the circles (top and bottom) are shaped into lines.
This happens when the up vector is the same as the face normal vector. By setting up = face.getNormal() all the NURBS shapes are transformed into lines.
Code I ran for this:

import pymel.core as pm

def orientationMatrix(aim, up, center=None):
    """
        Calculate a PyMel Matrix based on aim axis, up axis and potentially
        a center point (for translation offset).

        Using Gram schmidt process for orthonormalization
    """
    
    if center is None:
        center = pm.datatypes.Vector()

    # Create vectors and calc rotation
    # We don't do any prenormalization as we assume aim and up axis are normalized
    # So assuming for now that: getNormal() and the upVector we provided is normalized.
    tangent = (aim ^ up).normal()
    up = (tangent ^ aim).normal()
    
    # Set up the matrix
    mat = pm.datatypes.Matrix([aim.x, aim.y, aim.z, 0,
                               up.x, up.y, up.z, 0,
                               tangent.x, tangent.y, tangent.z, 0,
                               center.x,  center.y,  center.z,  1])
    return mat

# Testing
sphere = pm.polyCube()[0]
for face in sphere.faces:
    pm.runtime.CreateNURBSCircle()
    curve = pm.ls(sl=True)[0]

    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    up = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+

    mat = orientationMatrix(aim, up, center)
    pm.xform(curve, absolute=True, matrix=mat)

That is correct. You need to define the up axis of course! :wink: The one given here is scene up; so the secondary axis is always aiming at Y-axis up. As before we’re missing information from the face to orient the second axis.

It’s hard to define in consistent manner what is the correct orientation on a polygon face. Most often used is the tangent in the U direction of the UVs. This is somewhat similar to how one does tangent space vector displacemrnt. You can also get the tangents through the API with MFnMesh. Or you could use a direction of an edge between two vertices of the face to define the up vector.

The object becoming flat on the cube is because the aim vector is exactly in line with the upVector (so exact perpendicular is undefined). It should be fixable, but I’m not at a PC now to test some code. :wink: And the orthonormalization I wrote was out of the top of my head. You could also just use a temporary aimConstraint to do the orienting for you.

Typed from phone, but maybe this helps a bit.

Edit:
Also about it being perpendicular to the the face is because it uses the x axis of the object to point along the normal. Feel free to swap the aim, up and tangent vectors in the matrix to orient it diffently. Or apply a relative rotation in object space to correct if afterwards. (This should then be always the same value, eg [0, 90, 0])

I have swapped the arguments around and I can´t get this working. I can´t get all the NURBS shapes aligned to all faces. Swapping the up and the aim causes all NURBS shapes to be aligned with the Y-axis, and faces that are aligned to the XY plane transforms the circles into straight lines along Y.
Also, the center points of the faces appears to be incorrect:

The problem is that I don´t know Matrix math. Haven´t done any in over 10 years and just like with everything else you have no practical use for in ages, it gets forgotten. (Time to re-learn that shit I guess…)
But is it really necessary? I wrote a script a few months ago for getting the average face normal of a face selection, a script which then rotates a camera so it aligns with said vector (pointing towards the inverse vector of the average face normal) and from there doing a camera based UV projection. All I ever needed there was an angleBetween calculation between two vectors (like in my original post).

Thanks for questioning my technique here… that really opens up this to be a discussion instead of me just trying to point you towards a direction you might not need.

Actually looking at the angleBetween command it can return eulerAngles, so yes it should be all you need to define euler orientations.
In theory one should be able to define a relative rotation so that one vector ends up on top of the other.

For example:

import pymel.core as pm


# Testing
sphere = pm.polySphere()[0]
for face in sphere.faces:
    curve = pm.circle(normal=(0, 1, 0), radius=0.1)
    curve = pm.ls(sl=True)[0]

    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    origin = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+
    rot = pm.angleBetween(v1=origin , v2=aim, euler=True, ch=False)
   
    pm.xform(curve, absolute=True, t=center)
    pm.xform(curve, absolute=True, ro=rot)

But notice that it’s only orienting along a single axis and doesn’t define the secondary axis since you’re only taking into account the difference between two vectors (with angleBetween).
For example see what happens when you’re positioning cubes on the sphere:

import pymel.core as pm

def nodeToFace(node, face):
    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    origin = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+
    rot = pm.angleBetween(v1=origin, v2=aim, euler=True, ch=False)
   
    pm.xform(node, absolute=True, t=center)
    pm.xform(node, absolute=True, ro=rot)

# Testing 1
sphere = pm.polySphere()[0]
for face in sphere.faces:
    node = pm.polyCube(w=0.05, h=0.05, d=0.05)
    nodeToFace(node, face)
    
# Testing 2
sphere = pm.polySphere()[0]
pm.move(sphere, 2, 0, 0)
for face in sphere.faces:
    node = pm.circle(normal=(0, 1, 0), radius=0.1)
    nodeToFace(node, face)

With an additional angleBetween you should be able to orient the secondary axis to the tangent of the face. :slight_smile:
Or as stated before the aimConstraint should help you in a single step since you can directly define aim and up axis (plus it works with offsetted pivot points and/or different rotation orders).

Does this help you along?

Best,
Roy

EDIT:
Did some additional testing. Last one might be closest to what you’re looking for. I also tried orienting the second axis with angleBetween, but wasn’t sure about the math its doing behind the scenes so I couldn’t get that one right. I’m adding my try here anyway (first code block).

Note that these code snippets are a bit slow to run as they tend to do a lot looping and calculations with PyMel. Using new python api 2.0 you should be able to get this near instant (especially for these resolutions of meshes should be real-time!)

#1
Testing secondary axis orient with angleBetween (INCORRECT, sharing code anyway)

import pymel.core as pm


def nodeToFace(node, face):
    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    origin = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+
    rot = pm.angleBetween(v1=origin, v2=aim, euler=True, ch=False)
    
    # Nastiest code ever to get tangent in PyMel :O
    # SO sorry, I'm normally not much of a PyMel user. (Python API 2.0 FTW!)
    tangentId = face.__apimfn__().tangentIndex(0)
    fnMesh = pm.PyNode(face.node()).__apimfn__()
    tangents = maya.OpenMaya.MFloatVectorArray()
    fnMesh.getTangents(tangents)
    tangent = pm.datatypes.Vector(tangents[tangentId])
    
    pm.xform(node, absolute=True, t=center)
    pm.xform(node, absolute=True, ro=rot)
    
    mat = pm.xform(node, q=1, ws=1, matrix=1)
    localTangent = tangent * mat.inverse()
    origin = pm.datatypes.Vector([0.0, 0.0, 1.0]) # Z+
    rot = pm.angleBetween(v1=origin, v2=localTangent, euler=True, ch=False)
    pm.xform(node, relative=True, objectSpace=True, ro=rot)
    
    

# Testing 1
sphere = pm.polySphere()[0]
for face in sphere.faces:
    node = pm.polyCube(w=0.05, h=0.05, d=0.05)
    nodeToFace(node, face)
    
# Testing 2
sphere = pm.polySphere()[0]
pm.move(sphere, 2, 0, 0)
for face in sphere.faces:
    node = pm.circle(normal=(0, 1, 0), radius=0.1)
    nodeToFace(node, face)

#2
Orienting by normal and tangent with the matrix orientation. Same thing should be easily doable with aimConstraint (using same normal and tangent data)
If you’re going with PyMel the aimConstraint way is likely faster since it reduces the amount of initializing of Matrix datatype you need to do. :smiley:

import pymel.core as pm

import pymel.core as pm

def orientationMatrix(aim, up, center=None):
    """
        Calculate a PyMel Matrix based on aim axis, up axis and potentially
        a center point (for translation offset).

        Using Gram schmidt process for orthonormalization
    """
    
    if center is None:
        center = pm.datatypes.Vector()

    # Create vectors and calc rotation
    # We don't do any prenormalization as we assume aim and up axis are normalized
    # So assuming for now that: getNormal() and the upVector we provided is normalized.
    tangent = (aim ^ up).normal()
    up = (tangent ^ aim).normal()
    
    # Set up the matrix
    mat = pm.datatypes.Matrix([aim.x, aim.y, aim.z, 0,
                               up.x, up.y, up.z, 0,
                               tangent.x, tangent.y, tangent.z, 0,
                               center.x,  center.y,  center.z,  1])
               
    return mat

def nodeToFace(node, face):
    # Get face center
    pm.select(face, r=1)
    faceBox = pm.polyEvaluate(boundingBoxComponent=True)
    centerX = 0.5 * (faceBox[0][0] + faceBox[0][1])
    centerY = 0.5 * (faceBox[1][0] + faceBox[1][1])
    centerZ = 0.5 * (faceBox[2][0] + faceBox[2][1])
    center = pm.datatypes.Vector(centerX, centerY, centerZ)

    aim = face.getNormal() # Normal
    #origin = pm.datatypes.Vector([0.0, 1.0, 0.0]) # Y+
    #rot = pm.angleBetween(v1=origin, v2=aim, euler=True, ch=False)
    
    # Nastiest code ever to get tangent in PyMel :O
    # SO sorry, I'm normally not much of a PyMel user. (Python API 2.0 FTW!)
    # Get all tangents
    fnMesh = pm.PyNode(face.node()).__apimfn__()
    tangents = maya.OpenMaya.MFloatVectorArray()
    fnMesh.getTangents(tangents)
    
    # Get average tangent for this face (not that this does NOT provide a consistent tangent, eg. on cube?!)
    itMeshPolygon = face.__apimfn__()
    vtxCount = itMeshPolygon.polygonVertexCount()
    tangent = pm.datatypes.Vector()
    for i in range(vtxCount):
        tangentId = itMeshPolygon.tangentIndex(i)
        tangent += tangents[tangentId]
    tangent.normalize()
    
    mat = orientationMatrix(aim, tangent, center)
    pm.xform(node, absolute=True, matrix=mat)

# Testing 1
to_object = pm.polyCube(sx=5, sy=5, sz=5)[0]
for face in to_object.faces:
    node = pm.polyCube(w=0.05, h=0.05, d=0.05)
    nodeToFace(node, face)
    
# Testing 2
to_object = pm.polySphere()[0]
pm.move(to_object, 2, 0, 0)
for face in to_object.faces:
    node = pm.polyCube(w=0.05, h=0.05, d=0.05)
    nodeToFace(node, face)
    
# Testing 3
to_object = pm.polyCube()[0]
pm.move(to_object, 4, 0, 0)
for face in to_object.faces:
    node = pm.polyCube(w=0.05, h=0.05, d=0.05)
    nodeToFace(node, face)

this works for me


cmds.geometryConstraint('pSphere1', 'nurbsCircle1')
cmds.normalConstraint('pSphere1', 'nurbsCircle1', aim=[0.0, 1.0, 0.0])

all that is missing is getting the position of the face and updating the position of the circle.

with the constraints active, moving the circle over the sphere snaps the circle to the normal of each face.

Thanks a bunch Roy.
Yea I get what you mean with the first example: it makes the placement behave like the native “Snap together” -tool, which always rotates the source mesh in a funky way.
Your last example code works ! However the center point of the face isn´t calculated correctly and that is my fault. Going with the bounding box is just wrong: it will seldom give the geometric center - so I switched that part for a centroid calculation:


    centerX = 0.0
    centerY = 0.0
    centerZ = 0.0
    
    points = pm.ls(face.connectedVertices(), flatten=True)

    if len(points) == 3:
        p1 = points[0].getPosition()
        p2 = points[1].getPosition()
        p3 = points[2].getPosition()
        centerX = ( p1[0]+p2[0]+p3[0] ) / 3
        centerY = ( p1[1]+p2[1]+p3[1] ) / 3
        centerZ = ( p1[2]+p2[2]+p3[2] ) / 3
        
    elif len(points) == 4:
        p1 = points[0].getPosition()
        p2 = points[1].getPosition()
        p3 = points[2].getPosition()
        p4 = points[3].getPosition()
        centerX = ( p1[0]+p2[0]+p3[0]+p4[0] ) / 4
        centerY = ( p1[1]+p2[1]+p3[1]+p4[0] ) / 4
        centerZ = ( p1[2]+p2[2]+p3[2]+p4[0] ) / 4
        
    else:
        print("screw ngons!")    

…which ofc doesn’t work - and I don’t understand why. I thought this was the formula:

…and that adding another dimension is perfectly okay, but appearently not…

Either way I’m compelled to not use your code for the simple reason that I do not like to use code that I do not understand (It contains matrix calculations and API-code - neither which I grasp).
If I need to update or fix anything and I can’t read or understand how it works, well… you get the point.

I understand that you went with API to improve the speed of the script, which ofc is very important when dealing with a lot of calculations. In my case however, all I need is the placement of one single NURBS-shape onto one single face.
I hate to tell you this especially now after you’ve put some time into this.

Robert:
That´s very clean and neat! The only thing missing from that (other than what you mentioned) is to rotate the NURBS-shape around it’s object axis until it aligns with the average face tangent.
Can I calculate the average face tangent without using any API-code? This script only needs to work for a single face so latency due to heavy calculus isn’t going to be an issue.

EDIT:
Ignore the attachment. I tried to link to an image but all I got was a nanoscopic, pixelated thumbnail.

[QUOTE=Nightshade;26932]Thanks a bunch Roy.
Robert:
That´s very clean and neat! The only thing missing from that (other than what you mentioned) is to rotate the NURBS-shape around it’s object axis until it aligns with the average face tangent.
[/QUOTE]

I am not sure what you mean by this? I am guessing that your nurbs shape has a notion of “up” and “down” that needs to be aligned? In my case, I used a circle, so the orientation around the circles’ local Y axis was irrelevant.

Edit: getting the tangent data off a face does require the API, I am not aware of any MEL commands that can retrieve it. I am almost positive PyMel can get the tangent for you, since it wraps many of the API features.

From phone so keeping it short. :wink:

Getting the average center of the points is done by adding the vectors together and then dividing by the amount.

If pymel returns the points a Vector or Point data then:
center = sum(points) / len(points)

That is assuming the python operator has been implemented for the vector addition.


pointsPos = [pt.getPosition() for pt in points]
center = [0,0,0]
for pos in pointsPos:
    center = [x+y for x, y in zip(center, pos)]
center = [x/len(points) for x in center]

Woulf love to help u further, but things are busy at work. :wink: Good luck!