Skip to content
This repository has been archived by the owner on Sep 20, 2024. It is now read-only.

Maya: Implement iter_visible_nodes_in_range for extracting Alembics #3100

Merged
merged 12 commits into from
Jul 1, 2022
208 changes: 207 additions & 1 deletion openpype/hosts/maya/api/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1908,7 +1908,7 @@ def iter_parents(node):
"""
while True:
split = node.rsplit("|", 1)
if len(split) == 1:
if len(split) == 1 or not split[0]:
return

node = split[0]
Expand Down Expand Up @@ -3213,3 +3213,209 @@ def parent_nodes(nodes, parent=None):
node[0].setParent(node[1])
if delete_parent:
pm.delete(parent_node)


@contextlib.contextmanager
def maintained_time():
ct = cmds.currentTime(query=True)
try:
yield
finally:
cmds.currentTime(ct, edit=True)


def iter_visible_nodes_in_range(nodes, start, end):
"""Yield nodes that are visible in start-end frame range.

- Ignores intermediateObjects completely.
- Considers animated visibility attributes + upstream visibilities.

This is optimized for large scenes where some nodes in the parent
hierarchy might have some input connections to the visibilities,
e.g. key, driven keys, connections to other attributes, etc.

This only does a single time step to `start` if current frame is
not inside frame range since the assumption is made that changing
a frame isn't so slow that it beats querying all visibility
plugs through MDGContext on another frame.

Args:
nodes (list): List of node names to consider.
start (int, float): Start frame.
end (int, float): End frame.

Returns:
list: List of node names. These will be long full path names so
might have a longer name than the input nodes.

"""
# States we consider per node
VISIBLE = 1 # always visible
INVISIBLE = 0 # always invisible
ANIMATED = -1 # animated visibility

# Ensure integers
start = int(start)
end = int(end)

# Consider only non-intermediate dag nodes and use the "long" names.
nodes = cmds.ls(nodes, long=True, noIntermediate=True, type="dagNode")
if not nodes:
return

with maintained_time():
# Go to first frame of the range if the current time is outside
# the queried range so can directly query all visible nodes on
# that frame.
current_time = cmds.currentTime(query=True)
if not (start <= current_time <= end):
cmds.currentTime(start)

visible = cmds.ls(nodes, long=True, visible=True)
for node in visible:
yield node
if len(visible) == len(nodes) or start == end:
# All are visible on frame one, so they are at least visible once
# inside the frame range.
return

# For the invisible ones check whether its visibility and/or
# any of its parents visibility attributes are animated. If so, it might
# get visible on other frames in the range.
def memodict(f):
"""Memoization decorator for a function taking a single argument.

See: http://code.activestate.com/recipes/
578231-probably-the-fastest-memoization-decorator-in-the-/
"""

class memodict(dict):
def __missing__(self, key):
ret = self[key] = f(key)
return ret

return memodict().__getitem__

@memodict
def get_state(node):
plug = node + ".visibility"
connections = cmds.listConnections(plug,
source=True,
destination=False)
if connections:
return ANIMATED
else:
return VISIBLE if cmds.getAttr(plug) else INVISIBLE

visible = set(visible)
invisible = [node for node in nodes if node not in visible]
always_invisible = set()
# Iterate over the nodes by short to long names to iterate the highest
# in hierarchy nodes first. So the collected data can be used from the
# cache for parent queries in next iterations.
node_dependencies = dict()
for node in sorted(invisible, key=len):

state = get_state(node)
if state == INVISIBLE:
always_invisible.add(node)
continue

# If not always invisible by itself we should go through and check
# the parents to see if any of them are always invisible. For those
# that are "ANIMATED" we consider that this node is dependent on
# that attribute, we store them as dependency.
dependencies = set()
if state == ANIMATED:
dependencies.add(node)

traversed_parents = list()
for parent in iter_parents(node):

if parent in always_invisible or get_state(parent) == INVISIBLE:
# When parent is always invisible then consider this parent,
# this node we started from and any of the parents we
# have traversed in-between to be *always invisible*
always_invisible.add(parent)
always_invisible.add(node)
always_invisible.update(traversed_parents)
break

# If we have traversed the parent before and its visibility
# was dependent on animated visibilities then we can just extend
# its dependencies for to those for this node and break further
# iteration upwards.
parent_dependencies = node_dependencies.get(parent, None)
if parent_dependencies is not None:
dependencies.update(parent_dependencies)
break

state = get_state(parent)
if state == ANIMATED:
dependencies.add(parent)

traversed_parents.append(parent)

if node not in always_invisible and dependencies:
node_dependencies[node] = dependencies

if not node_dependencies:
return

# Now we only have to check the visibilities for nodes that have animated
# visibility dependencies upstream. The fastest way to check these
# visibility attributes across different frames is with Python api 2.0
# so we do that.
@memodict
def get_visibility_mplug(node):
"""Return api 2.0 MPlug with cached memoize decorator"""
sel = om.MSelectionList()
sel.add(node)
dag = sel.getDagPath(0)
return om.MFnDagNode(dag).findPlug("visibility", True)

@contextlib.contextmanager
def dgcontext(mtime):
"""MDGContext context manager"""
context = om.MDGContext(mtime)
try:
previous = context.makeCurrent()
yield context
finally:
previous.makeCurrent()

# We skip the first frame as we already used that frame to check for
# overall visibilities. And end+1 to include the end frame.
scene_units = om.MTime.uiUnit()
for frame in range(start + 1, end + 1):
mtime = om.MTime(frame, unit=scene_units)

# Build little cache so we don't query the same MPlug's value
# again if it was checked on this frame and also is a dependency
# for another node
frame_visibilities = {}
with dgcontext(mtime) as context:
for node, dependencies in list(node_dependencies.items()):
for dependency in dependencies:
dependency_visible = frame_visibilities.get(dependency,
None)
if dependency_visible is None:
mplug = get_visibility_mplug(dependency)
dependency_visible = mplug.asBool(context)
frame_visibilities[dependency] = dependency_visible

if not dependency_visible:
# One dependency is not visible, thus the
# node is not visible.
break

else:
# All dependencies are visible.
yield node
# Remove node with dependencies for next frame iterations
# because it was visible at least once.
node_dependencies.pop(node)

# If no more nodes to process break the frame iterations..
if not node_dependencies:
break
13 changes: 12 additions & 1 deletion openpype/hosts/maya/plugins/publish/extract_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from openpype.hosts.maya.api.lib import (
extract_alembic,
suspended_refresh,
maintained_selection
maintained_selection,
iter_visible_nodes_in_range
)


Expand Down Expand Up @@ -76,6 +77,16 @@ def process(self, instance):
# Since Maya 2017 alembic supports multiple uv sets - write them.
options["writeUVSets"] = True

if instance.data.get("visibleOnly", False):
# If we only want to include nodes that are visible in the frame
# range then we need to do our own check. Alembic's `visibleOnly`
# flag does not filter out those that are only hidden on some
# frames as it counts "animated" or "connected" visibilities as
# if it's always visible.
nodes = list(iter_visible_nodes_in_range(nodes,
start=start,
end=end))

with suspended_refresh():
with maintained_selection():
cmds.select(nodes, noExpand=True)
Expand Down
13 changes: 12 additions & 1 deletion openpype/hosts/maya/plugins/publish/extract_pointcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from openpype.hosts.maya.api.lib import (
extract_alembic,
suspended_refresh,
maintained_selection
maintained_selection,
iter_visible_nodes_in_range
)


Expand Down Expand Up @@ -79,6 +80,16 @@ def process(self, instance):
# Since Maya 2017 alembic supports multiple uv sets - write them.
options["writeUVSets"] = True

if instance.data.get("visibleOnly", False):
# If we only want to include nodes that are visible in the frame
# range then we need to do our own check. Alembic's `visibleOnly`
# flag does not filter out those that are only hidden on some
# frames as it counts "animated" or "connected" visibilities as
# if it's always visible.
nodes = list(iter_visible_nodes_in_range(nodes,
start=start,
end=end))

with suspended_refresh():
with maintained_selection():
cmds.select(nodes, noExpand=True)
Expand Down
51 changes: 51 additions & 0 deletions openpype/hosts/maya/plugins/publish/validate_visible_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import pyblish.api

import openpype.api
from openpype.hosts.maya.api.lib import iter_visible_nodes_in_range
import openpype.hosts.maya.api.action


class ValidateAlembicVisibleOnly(pyblish.api.InstancePlugin):
"""Validates at least a single node is visible in frame range.

This validation only validates if the `visibleOnly` flag is enabled
on the instance - otherwise the validation is skipped.

"""
order = openpype.api.ValidateContentsOrder + 0.05
label = "Alembic Visible Only"
hosts = ["maya"]
families = ["pointcache", "animation"]
actions = [openpype.hosts.maya.api.action.SelectInvalidAction]

def process(self, instance):

if not instance.data.get("visibleOnly", False):
self.log.debug("Visible only is disabled. Validation skipped..")
return

invalid = self.get_invalid(instance)
if invalid:
start, end = self.get_frame_range(instance)
raise RuntimeError("No visible nodes found in "
"frame range {}-{}.".format(start, end))

@classmethod
def get_invalid(cls, instance):

if instance.data["family"] == "animation":
# Special behavior to use the nodes in out_SET
nodes = instance.data["out_hierarchy"]
Copy link
Collaborator Author

@BigRoy BigRoy Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

It's good to be aware that this logic depends on THIS Collector plug-in.

The bug/issue is that if the instance in Maya is set to active=False on the objectSet/instance directly that that Collector does not run. This bug is present in many more areas actually so be aware.

There are two solutions to that:

  • Have the instance collector NOT collect the active state directly but instead collect as e.g. __active. Then have a very later Collector (CollectorOrder + 0.499) which sets instance.data["active"] = instance.data,pop("__active", True) - so that up to that point all collectors do run as expected.
  • Use/implement something like Post collectors like PYBLISH_QML_POST_COLLECT see: Implementing "Post Collect" pyblish/pyblish-qml#356 - that way we can have these collectors that should always run for an active instance in the post collection step after the user toggled states. Upside to this is that collecting will be MUCH faster since many collectors could move to the post collector order.

@mkolar Thoughts?

Copy link
Member

@iLLiCiTiT iLLiCiTiT Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that during local publishing (artist publishing where he can toggle instance activity) we process during collection all instances no matter if they are active or not (until collector check that on it's own?).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(until collector check that on it's own?).

It's any collector that appends data into an instance that would fail to do so for inactive instances. So I suspect the issue will also exist for these:

Basically anything running over the instance would not actually run because Collect Instances already collects directly whether the instance should be considered "active" to run over the instance for. So the issue is much more widespread than just this particular one.

else:
nodes = instance[:]

start, end = cls.get_frame_range(instance)
if not any(iter_visible_nodes_in_range(nodes, start, end)):
# Return the nodes we have considered so the user can identify
# them with the select invalid action
return nodes

@staticmethod
def get_frame_range(instance):
data = instance.data
return data["frameStartHandle"], data["frameEndHandle"]