Frame Objects with Perspective Camera with padding in Maya

I ran into a situation where the user needed a tool to reframe a perspective camera to an object while allowing to add a minimum padding between the object and the edge of the frame. Using cmds.viewFit(…) will get the selected object in view, but it doesn’t behave like I expected. Neither command zooms close enough so the object touches opposite edges of the frame; there’s always padding around the object.

This function moves the given camera into position so the view (within the resolution gate) will have the given objects in frame edge to edge, minus the given percentage padding. It uses a combination of trigonometry, matrix & vector math, and some bounding box hacking to get the info we need to move the camera into position. It only translates the camera; it does not perform scales or rotations, nor does it change any camera attributes like field of view. It tests for both horizontal and vertical FOVs and uses the larger Z value, thus making sure the entire object is in frame.

from math import (radians, tan)
from time import sleep

from maya.api.OpenMaya import (MMatrix, MPoint, MVector)
import maya.cmds as cmds


def frame_camera_to_objects(cam, objs, padding):
    """Frame given objects with given padding.  Make sure "Resolution Gate"
    is enabled.

    Args:
        cam (str): Name of camera node to move.
        objs (list[str]): Objects to frame.
        padding (float): Padding around the frame in percentage (0.0 - 1.0).

    Returns:
        maya.api.OpenMaya.MPoint: New Position of camera.
    """
    if not objs:
        return

    # Variables

    # Camera transform matrix.
    cam_tr = MMatrix(cmds.xform(cam, q=True, ws=True, matrix=True))
    cam_fov = cmds.camera(cam, q=True, horizontalFieldOfView=True)
    render_height = cmds.getAttr("defaultResolution.height")
    render_width = cmds.getAttr("defaultResolution.width")
    frame_ar = 1.0 * render_height / render_width  # frame aspect ratio.

    # Padding
    if padding < 0:
        # No negative padding.
        padding = 0.0
    # Since padding is on both sides of the frame, the highest padding available
    # should be less than 50%.
    # No padding * 2 == 100%.
    if padding * 2 > 0.998:
        padding = 0.499

    # Vectors

    # Since we want vectors representing each side of the FOV (minus padding),
    # they need to be normalized.
    # Divide FOV in 2 to create right angle with the image plane.
    # Assume distance to image plane (adjacent side) is 1.
    # Use tangent to calculate opposite side / adjacent side ratio.
    # Since adjacent side is 1, opposite side = tan(FOV/2)
    # Calculate length of opposite side minus total padding.
    # Build vector in relation to camera (as if camera were at origin),
    # then normalize.

    # # Tangents: Used to find the edge of FOV (minus padding).
    ang_ht = tan(radians(cam_fov / 2)) * (1.0 - padding * 2)
    ang_vt = ang_ht * frame_ar

    # Padding Vectors: Vectors representing edge of FOV (minus padding).
    vec_r = MVector(ang_ht, 0, -1).normalize()  # Right
    vec_l = MVector(-ang_ht, 0, -1).normalize()  # Left
    vec_b = MVector(0, -ang_vt, -1).normalize()  # Bottom
    vec_t = MVector(0, ang_vt, -1).normalize()  # Top

    # Make a FOV matrix where the x and y axes are aligned with the right and
    # left FOV (minus padding) vectors on the camera in world-space.
    c_tr_h_mtrx = MMatrix()  # Identity matrix.
    # Build orientation matrix.  X and Y axes will be at FOV (minus padding)
    # angle and might not be at right angle. Results will be sheared.
    c_tr_h_mtrx_data = [
        (vec_r.x, vec_r.y, vec_r.z),  # x-axis: right
        (vec_l.x, vec_l.y, vec_l.z),  # y-axis: left
        (0, 1, 0),  # z-axis: cam y-axis
        (0, 0, 0)]
    for r in range(3):
        for c in range(3):
            c_tr_h_mtrx.setElement(r, c, c_tr_h_mtrx_data[r][c])
    c_tr_h = c_tr_h_mtrx * cam_tr

    # Make a FOV matrix where the x and y axes are aligned with the bottom and
    # top FOV (minus padding) vectors on the camera in world-space.
    c_tr_v_mtrx = MMatrix()  # Identity matrix.
    # Build orientation matrix.  X and Y axes will be at FOV (minus padding)
    # angle and might not be at right angle. Results will be sheared.
    c_tr_v_mtrx_data = [
        (vec_b.x, vec_b.y, vec_b.z),  # x-axis: bottom
        (vec_t.x, vec_t.y, vec_t.z),  # y-axis: top
        (1, 0, 0),  # z-axis: cam x-axis
        (0, 0, 0)]
    for r in range(3):
        for c in range(3):
            c_tr_v_mtrx.setElement(r, c, c_tr_v_mtrx_data[r][c])
    c_tr_v = c_tr_v_mtrx * cam_tr

    # Min and max points of object bounding box in relation to each horizontal
    # and vertical FOV (minus padding) matrix.
    min_max_h = get_min_max(objs, c_tr_h)
    min_max_v = get_min_max(objs, c_tr_v)

    # Minimum Horizontal point in camera-space, converted from FOV matrix space.
    min_pos_h = MPoint(
        MPoint(min_max_h[0].x, min_max_h[0].y, 0) * c_tr_h * cam_tr.inverse())
    # Minimum Vertical point in camera-space, converted from FOV matrix space.
    min_pos_v = MPoint(
        MPoint(min_max_v[0].x, min_max_v[0].y, 0) * c_tr_v * cam_tr.inverse())

    # New Position in camera-space.
    new_pos = MPoint(
        min_pos_h.x,
        min_pos_v.y,
        min_pos_h.z if min_pos_h.z > min_pos_v.z else min_pos_v.z)

    # New Position in world-space.
    new_pos *= cam_tr

    cmds.xform(cam, ws=True, t=[new_pos.x, new_pos.y, new_pos.z])

    return new_pos


def get_min_max(objs, tr_matrix=MMatrix()):
    """Returns the absolute minimum and maximum points occupied by objs based on
    the given coordinate system.

    Args:
        objs (list[str]): List of objs to establish a bounding box.
        tr_matrix (maya.api.OpenMaya.MMatrix): Matrix to derive a
            coordinate system.  Defaults to world matrix.

    Returns:
        list[maya.api.OpenMaya.MPoint]: List of min and max points,
            i.e. [(xmin, ymin, zmin), (xmax, ymax, zmax)]
    """
    min_all = None
    max_all = None
    for obj in objs:
        node_bb = node_get_bounding_box(obj, tr_matrix)
        v_min = node_bb[0]
        v_max = node_bb[1]
        if min_all is None:
            min_all = v_min
            max_all = v_max
        else:
            if v_min.x < min_all.x:
                min_all.x = v_min.x
            if v_max.x > max_all.x:
                max_all.x = v_max.x
            if v_min.y < min_all.y:
                min_all.y = v_min.y
            if v_max.y > max_all.y:
                max_all.y = v_max.y
            if v_min.z < min_all.z:
                min_all.z = v_min.z
            if v_max.z > max_all.z:
                max_all.z = v_max.z
    if min_all is None:
        return None
    else:
        return [min_all, max_all]


def node_get_bounding_box(obj_nm, mtrx_coord=MMatrix()):
    """Returns a list of min and max points of bounding box relative to the
    given matrix coordinate system.

    Args:
        obj_nm (str): Name of object.
        mtrx_coord (maya.api.OpenMaya.MMatrix): Matrix whose coordinate system
            to use to orient the bounding box. Default is world matrix.

    Returns:
        list[maya.api.OpenMaya.MPoint]: Min and max points of bounding box
            relative to the given matrix coordinate system.
    """
    mtrx_obj_orig = MMatrix(cmds.xform(obj_nm, q=True, ws=True, matrix=True))

    # If camera were at the origin, this matrix transforms the object to
    # the same orientation relative to the camera.
    mtrx_obj_coord_relative = mtrx_obj_orig * mtrx_coord.inverse()
    # Move object relative to camera if camera were at the origin.
    cmds.xform(obj_nm, ws=True, m=mtrx_obj_coord_relative)

    # There are two ways to accomplish this:
    # 1) Duplicate the object, freeze transformations, get bounding box min/max
    # points, delete duplicate object.
    obj_copy_name = cmds.duplicate(
        obj_nm, name="{}_copy_delete_me".format(obj_nm))[0]
    # Freeze Transformations, thus aligning the local bbox with world space.
    cmds.makeIdentity(
        obj_copy_name, apply=True, translate=True, rotate=True, scale=True)
    # Get Bounding box in world space.
    xmin, ymin, zmin, xmax, ymax, zmax = cmds.exactWorldBoundingBox(
        obj_copy_name)
    cmds.delete(obj_copy_name)

    # 2) Freeze transformations on original object, get bounding box min/max
    # points, undo freeze transformations.
    # cmds.makeIdentity(
    #     obj_nm, apply=True, translate=True, rotate=True, scale=True)
    # xmin, ymin, zmin, xmax, ymax, zmax = cmds.exactWorldBoundingBox(obj_nm)
    # cmds.undo()

    # Put object back into original position.
    cmds.xform(obj_nm, ws=True, m=mtrx_obj_orig)

    return [MPoint(xmin, ymin, zmin), MPoint(xmax, ymax, zmax)]


if __name__ == "__main__":
    cam_name = "camera1"
    selection = cmds.ls(selection=True)

    if selection:
        for p in [0.0, 0.10, 0.20, 0.30, 0.40, 0.50]:
            frame_camera_to_objects(cam_name, selection, p)
            cmds.refresh()
            sleep(1)
        cmds.select(selection, replace=True)

        cmds.viewFit(selection)