Skip to content

Instantly share code, notes, and snippets.

@BigRoy
Last active March 21, 2024 06:18
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save BigRoy/60883eff23f73a34c4671395b32d858d to your computer and use it in GitHub Desktop.
Save BigRoy/60883eff23f73a34c4671395b32d858d to your computer and use it in GitHub Desktop.
Query the used UDIM tiles for the UVs of an input mesh
from maya import cmds
def get_bounding_box_tiles(bb):
u_minmax, v_minmax = bb
# If the max is exactly on the integer boundary we allow it to be
# part of the tile of the previos integer so we can subtract one.
# But since we'll need to add one to iterate "up to" the maximum for
# the `range` function we will instead add one to the opposite cases.
# Inputs are tuples so don't assign elements but override tuple
if u_minmax[1] != int(u_minmax[1]):
u_minmax = (u_minmax[0], u_minmax[1] + 1)
if v_minmax[1] != int(v_minmax[1]):
v_minmax = (v_minmax[0], v_minmax[1] + 1)
tiles = []
for v in range(*map(int, v_minmax)):
for u in range(*map(int, u_minmax)):
tiles.append((u, v))
return tiles
def get_uv_udim_tiles(mesh, uv_set=None):
"""Return the UV tiles used by the UVs of the input mesh.
Warning:
This does not capture the case where a single UV shell
might be layout in such a way that all its UV points are
around another tile since it uses the UV shell bounding
box to compute the used tiles. In the image below imagine
the lines being the UVs of a single shell and the `x` being
an emtpy UV tile. It will not detect the empty tile.
/ - \
| x |
Args:
mesh (str): Mesh node name.
uv_set (str): The UV set to sample. When not
provided the current UV map is used.
Returns:
list: sorted list of uv tiles
"""
kwargs = {}
if uv_set is not None:
kwargs["uvSetName"] = uv_set
bb = cmds.polyEvaluate(mesh, boundingBox2d=True, **kwargs)
tiles = get_bounding_box_tiles(bb)
if len(tiles) == 1:
# If there's only a single tile for the bounding box
# it'll be impossible for there to be empty tiles
# in-between so we just return the given tiles
return tiles
# Get the bounding box per UV shell
uv_shells = cmds.polyEvaluate(mesh, uvShell=True, **kwargs)
if uv_shells == 1:
# If there's only a single UV shell it must span
# all the UV tiles
return tiles
tiles = set()
for i in range(uv_shells):
shell_uvs = cmds.polyEvaluate(mesh, uvsInShell=i, **kwargs)
shell_bb = cmds.polyEvaluate(shell_uvs, boundingBoxComponent2d=True, **kwargs)
shell_tiles = get_bounding_box_tiles(shell_bb)
tiles.update(shell_tiles)
return sorted(tiles, key=uv2udim)
def uv2udim(tile):
"""UV tile to UDIM number.
Note that an input integer of 2 means it's
the UV tile range using 2.0-3.0.
Examples:
>>> uv2udim((0, 0)
# 1001
>>> uv2udim((0, 1)
# 1011
>>> uv2udim((2, 0)
# 1003
>>> uv2udim(8, 899)
# 9999
Returns:
int: UDIM tile number
"""
u, v = tile
return 1001 + u + 10 * v
# Example usage
for mesh in cmds.ls(selection=True):
tiles = get_uv_udim_tiles(mesh)
print mesh
print tiles
for tile in tiles:
print uv2udim(tile)
@BigRoy
Copy link
Author

BigRoy commented Feb 19, 2021

To speed up one of the examples from the Maya - Fast UDIM tile suggestions topic here's code using Maya Python API 2.0.

import math
import maya.api.OpenMaya as om

def get_uvs(mesh):
    """Return UVs as list of two arrays [u_points, v_points]
    
    Uses Python API 2.0 for optimal perfomance.
    """
    sel = om.MSelectionList()
    sel.add(mesh)
    fn = om.MFnMesh(sel.getDependNode(0))
    return fn.getUVs()
 
def uv2udim(u,v):
    """return UDIM tile corresponding to UV coord"""
    return 1001+ int(u) + int(v) * 10
    
def get_mesh_udim_tiles(mesh):
    uvs = get_uvs(mesh)
    udims = set()
    for u, v in zip(uvs[0], uvs[1]):
        udim = uv2udim(u, v)
        udims.add(udim)
    return sorted(udims)

# Example usage (will go over all meshes in the scene!)
for mesh in cmds.ls(type="mesh", selection=True):
    print mesh
    print get_mesh_udim_tiles(mesh)

The detection of UDIM tiles here checks solely by a UV point position being in a specific UDIM tile. Since this goes over every UV point this is slower for meshes with lots of points compared to this gist solution - especially if amount of UV shells is relatively limited.

Additionally, the detection will have some issues with UVs being on a UV tile boundary, e.g. a default cube hits the 1.0 of the V-axis and is detected as the second UV tile. The gist code solution above captures those cases correctly.

@BigRoy
Copy link
Author

BigRoy commented Feb 19, 2021

Similar kind of code as in the gist but then getting the bounding boxes in Python using the UVs and shells we get with Maya Python API 2.0. I'm expecting this to be SLOWER than the gist code at top due to the conversion of all point positions to Python and the computations being done in Python as opposed to the bounding box + points being in C++ when using maya.cmds.polyEvaluate(shell_uvs, boundingBoxComponent2d=True).

Plus using this code we can't also opt-out early with the global bounding box if all is in a single tile or if there's only one UV shell. Those are cases that would be comparably extremely fast with the gist code at the top - especially on meshes with a high UV point count.

import math
import maya.api.OpenMaya as om
from collections import defaultdict
        
        
def get_bounding_box_tiles(bb):
    u_minmax, v_minmax = bb
    
    # If the max is exactly on the integer boundary we allow it to be 
    # part of the tile of the previos integer so we can subtract one.
    # But since we'll need to add one to iterate "up to" the maximum for 
    # the `range` function we will instead add one to the opposite cases.
    # Inputs are tuples so don't assign elements but override tuple
    if u_minmax[1] != int(u_minmax[1]):
        u_minmax = (u_minmax[0], u_minmax[1] + 1)
    if v_minmax[1] != int(v_minmax[1]):
        v_minmax = (v_minmax[0], v_minmax[1] + 1)
        
    tiles = []
    for v in range(*map(int, v_minmax)):
        for u in range(*map(int, u_minmax)):
            tiles.append((u, v))
    return tiles


def get_uv_tiles(mesh, precision=None):
    """Return UV tiles for mesh"""
    sel = om.MSelectionList()
    sel.add(mesh)
    fn = om.MFnMesh(sel.getDependNode(0))
    
    # Get UV shells and the UV indices
    num_shells, shell_indices = fn.getUvShellsIds()
    uvs_per_shell = defaultdict(list)
    for i, index in enumerate(shell_indices):
        uvs_per_shell[index].append(i)
        
    # Define bounding box for each shell
    u, v = fn.getUVs()
    all_tiles = set()
    for shell_index, uv_indices in uvs_per_shell.items():
        u_min = min(u[i] for i in uv_indices)
        u_max = max(u[i] for i in uv_indices)
        v_min = min(v[i] for i in uv_indices)
        v_max = max(v[i] for i in uv_indices)
        
        if precision:
            # In some cases reducing floating point precision
            # can work around false positives for a new tile
            # e.g. a default sphere can have a UV point at
            # V = 1.0000001192092896 - which will make it
            # think the second vertical UV tile is used.
            u_min = round(u_min, precision)
            u_max = round(u_max, precision)
            v_min = round(v_min, precision)
            v_max = round(v_max, precision)
        
        bb = ((u_min, u_max), (v_min, v_max))
        tiles = get_bounding_box_tiles(bb)
        all_tiles.update(tiles)
        
    return sorted(all_tiles)
 
 
def uv2udim(u,v):
    """return UDIM tile corresponding to UV coord"""
    return 1001+ int(u) + int(v) * 10


# Example usage (will go over all meshes in the scene!)
for mesh in cmds.ls(type="mesh"):
    print mesh
    print [uv2udim(*tile) for tile in get_uv_tiles(mesh, precision=4)]

@BigRoy
Copy link
Author

BigRoy commented Mar 3, 2021

For sake of testing here are some test simple meshes with their expected tiles in Maya.

from maya import cmds

tests = []

# Plane with all UV points outside outside of Tile [0,0]
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="outside")[0]
cmds.polyEditUV(plane + ".map[0]", u=-0.1, v=-0.1)
cmds.polyEditUV(plane + ".map[1]", u=0.1, v=-0.1)
cmds.polyEditUV(plane + ".map[2]", u=-0.1, v=0.1)
cmds.polyEditUV(plane + ".map[3]", u=0.1, v=0.1)
expected = [(x, y) for x in range(-1, 2) for y in range(-1, 2)]
tests.append((plane, expected))

# Plane with all UV points inside of Tile [0,0]
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="inside")[0]
cmds.polyEditUV(plane + ".map[0]", u=0.1, v=0.1)
cmds.polyEditUV(plane + ".map[1]", u=-0.1, v=0.1)
cmds.polyEditUV(plane + ".map[2]", u=0.1, v=-0.1)
cmds.polyEditUV(plane + ".map[3]", u=-0.1, v=-0.1)
expected = [(0, 0)]
tests.append((plane, expected))

# Plane with all UV points on the borders of Tile [0,0]
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="all_on_borders")[0]
expected = [(0, 0)]
tests.append((plane, expected))

# Plane with all UV points inside of Tile [0,0]
# With bottom right UV point extended into tile [1,0]
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="inside_except_one_point")[0]
cmds.polyEditUV(plane + ".map[0]", u=0.1, v=0.1)
cmds.polyEditUV(plane + ".map[1]", u=0.5, v=0.1)
cmds.polyEditUV(plane + ".map[2]", u=0.1, v=-0.1)
cmds.polyEditUV(plane + ".map[3]", u=-0.1, v=-0.1)
expected = [(0, 0), (1, 0)]
tests.append((plane, expected))

# Plane with bottom right UV point extended into tile [2,0]
# Has no UV points in tile [1,0] but polygon does go over it
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="extended_over")[0]
cmds.polyEditUV(plane + ".map[1]", u=1.9)
cmds.polyEditUV(plane + ".map[3]", u=-0.1)
expected = [(0, 0), (1, 0), (2, 0)]
tests.append((plane, expected))

# Plane skewed from tile [0,0] over to tile [2,2]
# This should be invalid with just a bounding box check
plane = cmds.polyPlane(createUVs=True, sx=1, sy=1, name="skewed")[0]
cmds.polyEditUV(plane + ".map[1]", u=2, v=2)
cmds.polyEditUV(plane + ".map[3]", u=2, v=2)
expected = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 1), (1, 2), (2, 2)]
tests.append((plane, expected))


def run_tests(fn, tests):
    for geo, expected in tests:
        result = fn(geo)
        correct = sorted(result) == sorted(expected)
        
        print("Geo: %s" % geo)
        if correct:
            print("OK!")
        else:
            print("Invalid.")
            print("\tResult:   %s" % result)
            print("\tExpected: %s" % expected)

Example usage with a function called get_uv_udim_tiles

run_tests(get_uv_udim_tiles, tests)

@BigRoy
Copy link
Author

BigRoy commented Mar 3, 2021

Here's one that uses polyUVCoverage to remove the false positives from the bounding box queries:

from maya import cmds
import math

    
def get_bounding_box_tiles(bb):
    u_minmax, v_minmax = bb
    
    # If the max is exactly on the integer boundary we allow it to be 
    # part of the tile of the previos integer so we can subtract one.
    # But since we'll need to add one to iterate "up to" the maximum for 
    # the `range` function we will instead add one to the opposite cases.
    # Inputs are tuples so don't assign elements but override tuple
    if u_minmax[1] != math.floor(u_minmax[1]):
        u_minmax = (u_minmax[0], u_minmax[1] + 1)
    if v_minmax[1] != math.floor(v_minmax[1]):
        v_minmax = (v_minmax[0], v_minmax[1] + 1)
        
    def floor_int(x):
        return int(math.floor(x))
        
    tiles = []
    for v in range(*map(floor_int, v_minmax)):
        for u in range(*map(floor_int, u_minmax)):
            tiles.append((u, v))
    return tiles


def get_uv_udim_tiles(mesh, uv_set=None):
    """Return the UV tiles used by the UVs of the input mesh.
    
    Warning:
         This does not capture the case where a single UV shell
         might be layout in such a way that all its UV points are
         around another tile since it uses the UV shell bounding
         box to compute the used tiles. In the image below imagine 
         the lines being the UVs of a single shell and the `x` being 
         an emtpy UV tile. It will not detect the empty tile.
         
         / - \
         | x |
    
    Args:
        mesh (str): Mesh node name.
        uv_set (str): The UV set to sample. When not
            provided the current UV map is used.
            
    Returns:
        list: sorted list of uv tiles
    
    """
    
    def _remove_false_positives(uvs, tiles):
        """Confirm tiles returned from bounding box.
        
        This will remove the false positives.
        """
        if len(tiles) < 2:
            # A single tile from bounding
            # box must always be correct
            return tiles
        
        faces = cmds.polyListComponentConversion(uvs, 
                                                 fromUV=True, 
                                                 toFace=True, 
                                                 internal=True)
        confirmed_tiles = []
        for tile in tiles:
            coverage = cmds.polyUVCoverage(faces, 
                                           uvRange=[tile[0], 
                                                    tile[1], 
                                                    tile[0]+1, 
                                                    tile[1]+1])[0]
            if coverage > 0.0:
                confirmed_tiles.append(tile)
        return confirmed_tiles
        
    kwargs = {}
    if uv_set is not None:
        kwargs["uvSetName"] = uv_set

    bb = cmds.polyEvaluate(mesh, boundingBox2d=True, **kwargs)
    tiles = get_bounding_box_tiles(bb)
    if len(tiles) == 1:
        # If there's only a single tile for the bounding box
        # it'll be impossible for there to be empty tiles
        # in-between so we just return the given tiles
        return tiles
    
    # Get the bounding box per UV shell
    uv_shells = cmds.polyEvaluate(mesh, uvShell=True, **kwargs)
    if uv_shells == 1:
        # If there's only a single UV shell it must span
        # all the UV tiles
        return _remove_false_positives(mesh + ".map[*]", tiles)
    
    tiles = set()
    for i in range(uv_shells):
        shell_uvs = cmds.polyEvaluate(mesh, uvsInShell=i, **kwargs)
        shell_bb = cmds.polyEvaluate(shell_uvs, boundingBoxComponent2d=True, **kwargs)
        shell_tiles = get_bounding_box_tiles(shell_bb)
        shell_tiles = _remove_false_positives(shell_uvs, shell_tiles)
        
        tiles.update(shell_tiles)
        
    return sorted(tiles)
        
        
def uv2udim(tile):
    """UV tile to UDIM number.
    
    Note that an input integer of 2 means it's
    the UV tile range using 2.0-3.0.
    
    Examples:
        >>> uv2udim((0, 0)
        # 1001
        >>> uv2udim((0, 1)
        # 1011
        >>> uv2udim((2, 0)
        # 1003
        >>> uv2udim(8, 899)
        # 9999
    
    Returns:
        int: UDIM tile number
        
    """
    u, v = tile    
    return 1001 + u + 10 * v 


# Example usage
for mesh in cmds.ls(selection=True):
    tiles = get_uv_udim_tiles(mesh)
    
    print mesh
    print tiles
    for tile in tiles:
        print uv2udim(tile)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment