Accessing Object Properties and Controllers

Working with nested properties, such as controllers, in pymxs requires a different approach than in MAXScript. MAXScript "knows" about nested object properties, and does some extra parsing when it encounters them. Python and MAXScript treat properties indicated with dot notation differently: Python always returns a reference to the indicated property, while MAXScript returns a copy of the value. Attempting to assign a value may not work as intended in pymxs.

Note:
Note: There are some exceptions to this situation, as when the underlying property is a reference. So something like box.controller.position will work, because controller is a reference.

For example, in MAXScript you can do this:

b = box()
b.pos.x = 20

The equivalent in pymxs does not work as intended, because b.pos.x is returning a copy of the pos object, which is no longer connected to the box's position.

There are three solutions to this problem:

  1. Use the MAXScript getProperty() and setProperty() functions
  2. Use the pymxs MXSWrapperBase getmxsprop() and setmxsprop() functions
  3. Work on a copy of the target property, and assign the object back

For example:

from pymxs import runtime as mxs
b = mxs.box()

b.pos.x = 9  # trying to set the x value of the box position, but we got a copy of the position
print(b.pos.x)  # see, we are still at pos.x == 0

# solution: use MXSWrapper.setmxsprop()
b.setmxsprop("pos.x", 9.0)  # properly set the nested property value
print(b.pos.x)  # the x value of the box position is now at 9


# OR use MAXScript setProperty()
mxs.setProperty(b, "pos.x", 10.0)
print(mxs.getProperty(b, "pos.x"))

# you can also do this:
b_pos = b.pos
b_pos.x = 10  # set the copied value from the box property
b.pos = b_pos  # properly apply back the changed pos value to the box
print(b.pos.x)  # the x value of the box position is now at 10

# getting the position:
print(b.pos.x)  # 10.0 - fetches the proper value, but it is a copy of the position
print(b.getmxsprop("pos.x")) # 10.0 - fetches the proper value
print(mxs.getProperty(b, "pos.x")) # 10.0 - also works

Working with Controllers

Here is an example of some MAXScript code:

myTeapot = teapot()
myTeapot.pos.controller = noise_position()

In MAXScript, as in Python, myTeapot.pos is a Point3, which doesn't have an associated controller property. Attempting to get myTeapot.pos.controller in Python results in an error such as: AttributeError: 'pymxs.MXSWrapperBase' object has no attribute 'controller'.

But in MAXScript myTeapot.pos.controller is perfectly valid, as it evaluates to an object structure hierarchy. Therefore, to achieve the same thing using pymxs, we must use a different approach and use getPropertyController() / setPropertyController().

Here's a complete example where we set the Position XYZ controller of a box, and the X component of the position for a sphere:

from pymxs import runtime as rt

# set the transform -> position controller
t = rt.teapot()
rt.setPropertyController( t.controller, 'Position', rt.noise_position())
print(rt.getPropertyController(t.controller, 'Position'))

# set the transform -> position -> x_position controller
s = rt.sphere()
sphr_pos_ctrl = rt.getPropertyController(s.controller, 'Position')
rt.setPropertyController(sphr_pos_ctrl, 'X Position', rt.noise_float())
print(rt.getPropertyController(sphr_pos_ctrl, 'X Position'))

Output:

Controller:Noise_Position
Controller:Noise_Float

Expression Controllers

Expression controllers are created and set in the same way, though they require the extra steps of adding an expression and (if required) scalar and vector targets. In this example, we add an expression controller to the X Position sub-controller of an object's position controller, so that its X position always matches a target object.

Note: The name of the controller you pass to getPropertyController() is the same string that appears in the Track View - Curve Editor in the 3ds Max UI.
from pymxs import runtime as rt

# target:
s = rt.sphere()

t = rt.teapot(pos=rt.point3(20,20,0))
s_pos_ctl = rt.getPropertyController(s.controller, 'Position')
expr_pos_ctl = rt.position_expression()
# first add the scalar variable targeting the sphere's X position
expr_pos_ctl.addScalarTarget('sphere_x', rt.getPropertyController(s_pos_ctl, 'X Position'))
# then add the expression that uses that variable
expr_pos_ctl.setExpression('[sphere_x, 20,0]')
# finally, set the controller
rt.setPropertyController(t.controller, "Position", expr_pos_ctl)

Assigning Controllers Where There is not an Existing Controller

The transform properties of an object has default controllers, and can be accessed and modified as explained above. Adding a controller to a property that does not currently have one with pymxs is not as straightforward, because there is no name associated with the property's controller.

3ds Max stores all the controllers for properties for an object in a structure called a subanim (sub-animatable). A subanim can contain nested subanims. To assign a controller to a property that does not have a controller, we need to find the property's location in the subanim tree structure by index, and assign the controller to the result. We can do this using the MAXScript function getSubanim().

For example, to assign a float controller to an object's width property:

from pymxs import runtime as mxs
b = mxs.box()
bw= mxs.getSubAnim(mxs.getSubAnim(b, 4), 2)
mxs.setProperty(bw, 'controller', mxs.bezier_float())

This gets the box node's fourth subAnim (the box object), and then gets the second subAnim (the width property).

Note that the getSubAnim() function gets the subAnim, while getPropertyController() (illustrated above) gets the controller for the subAnim. So to expand the previous example:

from pymxs import runtime as mxs
b = mxs.box()
# get the subAnim for X_Rotation:
brx = mxs.getSubAnim(mxs.getSubAnim(mxs.getSubAnim(b, 3), 2), 1) 
# get the controller for X_Rotation:
brxc = mxs.getPropertyController(mxs.getPropertyController(b.controller, 'Rotation'), 'X Rotation')
brx.controller == brxc # this is True

This example illustrates visiting all the subAnims in an object's subAnim tree, and prints out the corresponding names and indices. It can be used to determine the correct indices to use to assign new controllers:

from pymxs import runtime as mxs

b = mxs.box()

for sub_i in range(b.numsubs):
    sub = mxs.getSubAnim(b, sub_i+1)
    print(f'{sub.name} [{sub_i+1}]')

    if sub.controller == None:
        print(' No controller assigned to it')
    else:
        print(f' current controller: {(sub.controller)}')
    if sub.keys == None:
        print(' No keys assigned to it')

    for secsub_i in range(sub.numsubs):
        secsub = mxs.getSubAnim(sub, secsub_i+1)
        print(f'\t{secsub.name} [{secsub_i+1}]')

        if secsub.controller == None:
            print('\t No controller assigned to it')
        else:
            print(f'\t current controller: {(secsub.controller)}')
        if secsub.keys == None:
            print('\t No keys assigned to it')
        for thirdsub_i in range(secsub.numsubs):
            thirdsub = mxs.getSubAnim(secsub, thirdsub_i+1)
            print(f'\t\t{thirdsub.name} [{thirdsub_i+1}]')
            if secsub.controller == None:
                print('\t No controller assigned to it')
            else:
                print(f'\t\t current controller: {(thirdsub.controller)}')

This displays the following output:

Visibility [1]
 No controller assigned to it
 No keys assigned to it
Space Warps [2]
 No controller assigned to it
 No keys assigned to it
Transform [3]
 current controller: Controller:Position_Rotation_Scale
    Position [1]
     current controller: Controller:Position_XYZ
        X Position [1]
         current controller: Controller:Bezier_Float
        Y Position [2]
         current controller: Controller:Bezier_Float
        Z Position [3]
         current controller: Controller:Bezier_Float
    Rotation [2]
     current controller: Controller:Euler_XYZ
        X Rotation [1]
         current controller: Controller:Bezier_Float
        Y Rotation [2]
         current controller: Controller:Bezier_Float
        Z Rotation [3]
         current controller: Controller:Bezier_Float
    Scale [3]
     current controller: Controller:Bezier_Scale
Object (Box) [4]
 No controller assigned to it
 No keys assigned to it
    Length [1]
     No controller assigned to it
     No keys assigned to it
    Width [2]
     No controller assigned to it
     No keys assigned to it
    Height [3]
     No controller assigned to it
     No keys assigned to it
    Width Segments [4]
     No controller assigned to it
     No keys assigned to it
    Length Segments [5]
     No controller assigned to it
     No keys assigned to it
    Height Segments [6]
     No controller assigned to it
     No keys assigned to it
Material [5]
 No controller assigned to it
 No keys assigned to it
Image Motion Blur Multiplier [6]
 No controller assigned to it
 No keys assigned to it
Object Motion Blur On Off [7]
 No controller assigned to it
 No keys assigned to it