diff --git a/openpype/hosts/houdini/api/action.py b/openpype/hosts/houdini/api/action.py
new file mode 100644
index 0000000000..27e8ce55bb
--- /dev/null
+++ b/openpype/hosts/houdini/api/action.py
@@ -0,0 +1,46 @@
+import pyblish.api
+import hou
+
+from openpype.pipeline.publish import get_errored_instances_from_context
+
+
+class SelectInvalidAction(pyblish.api.Action):
+ """Select invalid nodes in Maya when plug-in failed.
+
+ To retrieve the invalid nodes this assumes a static `get_invalid()`
+ method is available on the plugin.
+
+ """
+ label = "Select invalid"
+ on = "failed" # This action is only available on a failed plug-in
+ icon = "search" # Icon from Awesome Icon
+
+ def process(self, context, plugin):
+
+ errored_instances = get_errored_instances_from_context(context)
+
+ # Apply pyblish.logic to get the instances for the plug-in
+ instances = pyblish.api.instances_by_plugin(errored_instances, plugin)
+
+ # Get the invalid nodes for the plug-ins
+ self.log.info("Finding invalid nodes..")
+ invalid = list()
+ for instance in instances:
+ invalid_nodes = plugin.get_invalid(instance)
+ if invalid_nodes:
+ if isinstance(invalid_nodes, (list, tuple)):
+ invalid.extend(invalid_nodes)
+ else:
+ self.log.warning("Plug-in returned to be invalid, "
+ "but has no selectable nodes.")
+
+ hou.clearAllSelected()
+ if invalid:
+ self.log.info("Selecting invalid nodes: {}".format(
+ ", ".join(node.path() for node in invalid)
+ ))
+ for node in invalid:
+ node.setSelected(True)
+ node.setCurrent(True)
+ else:
+ self.log.info("No invalid nodes found.")
diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml
deleted file mode 100644
index 0f92560bf7..0000000000
--- a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-Scene setting
-
-## Invalid input node
-
-VDB input must have the same number of VDBs, points, primitives and vertices as output.
-
-
-
-### __Detailed Info__ (optional)
-
-A VDB is an inherited type of Prim, holds the following data:
- - Primitives: 1
- - Points: 1
- - Vertices: 1
- - VDBs: 1
-
-
-
\ No newline at end of file
diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml
new file mode 100644
index 0000000000..eb83bfffe3
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml
@@ -0,0 +1,28 @@
+
+
+
+Invalid VDB
+
+## Invalid VDB output
+
+All primitives of the output geometry must be VDBs, no other primitive
+types are allowed. That means that regardless of the amount of VDBs in the
+geometry it will have an equal amount of VDBs, points, primitives and
+vertices since each VDB primitive is one point, one vertex and one VDB.
+
+This validation only checks the geometry on the first frame of the export
+frame range.
+
+
+
+
+
+### Detailed Info
+
+ROP node `{rop_path}` is set to export SOP path `{sop_path}`.
+
+{message}
+
+
+
+
\ No newline at end of file
diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py
deleted file mode 100644
index 1f9ccc9c42..0000000000
--- a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# -*- coding: utf-8 -*-
-import pyblish.api
-from openpype.pipeline import (
- PublishValidationError
-)
-
-
-class ValidateVDBInputNode(pyblish.api.InstancePlugin):
- """Validate that the node connected to the output node is of type VDB.
-
- Regardless of the amount of VDBs create the output will need to have an
- equal amount of VDBs, points, primitives and vertices
-
- A VDB is an inherited type of Prim, holds the following data:
- - Primitives: 1
- - Points: 1
- - Vertices: 1
- - VDBs: 1
-
- """
-
- order = pyblish.api.ValidatorOrder + 0.1
- families = ["vdbcache"]
- hosts = ["houdini"]
- label = "Validate Input Node (VDB)"
-
- def process(self, instance):
- invalid = self.get_invalid(instance)
- if invalid:
- raise PublishValidationError(
- self,
- "Node connected to the output node is not of type VDB",
- title=self.label
- )
-
- @classmethod
- def get_invalid(cls, instance):
-
- node = instance.data["output_node"]
-
- prims = node.geometry().prims()
- nr_of_prims = len(prims)
-
- nr_of_points = len(node.geometry().points())
- if nr_of_points != nr_of_prims:
- cls.log.error("The number of primitives and points do not match")
- return [instance]
-
- for prim in prims:
- if prim.numVertices() != 1:
- cls.log.error("Found primitive with more than 1 vertex!")
- return [instance]
diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
index f9f88b3bf9..674782179c 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
@@ -1,14 +1,73 @@
# -*- coding: utf-8 -*-
+import contextlib
+
import pyblish.api
import hou
-from openpype.pipeline import PublishValidationError
+
+from openpype.pipeline import PublishXmlValidationError
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+
+
+def group_consecutive_numbers(nums):
+ """
+ Args:
+ nums (list): List of sorted integer numbers.
+
+ Yields:
+ str: Group ranges as {start}-{end} if more than one number in the range
+ else it yields {end}
+
+ """
+ start = None
+ end = None
+
+ def _result(a, b):
+ if a == b:
+ return "{}".format(a)
+ else:
+ return "{}-{}".format(a, b)
+
+ for num in nums:
+ if start is None:
+ start = num
+ end = num
+ elif num == end + 1:
+ end = num
+ else:
+ yield _result(start, end)
+ start = num
+ end = num
+ if start is not None:
+ yield _result(start, end)
+
+
+@contextlib.contextmanager
+def update_mode_context(mode):
+ original = hou.updateModeSetting()
+ try:
+ hou.setUpdateMode(mode)
+ yield
+ finally:
+ hou.setUpdateMode(original)
+
+
+def get_geometry_at_frame(sop_node, frame, force=True):
+ """Return geometry at frame but force a cooked value."""
+ with update_mode_context(hou.updateMode.AutoUpdate):
+ sop_node.cook(force=force, frame_range=(frame, frame))
+ return sop_node.geometryAtFrame(frame)
class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
"""Validate that the node connected to the output node is of type VDB.
- Regardless of the amount of VDBs create the output will need to have an
- equal amount of VDBs, points, primitives and vertices
+ All primitives of the output geometry must be VDBs, no other primitive
+ types are allowed. That means that regardless of the amount of VDBs in the
+ geometry it will have an equal amount of VDBs, points, primitives and
+ vertices since each VDB primitive is one point, one vertex and one VDB.
+
+ This validation only checks the geometry on the first frame of the export
+ frame range for optimization purposes.
A VDB is an inherited type of Prim, holds the following data:
- Primitives: 1
@@ -22,54 +81,95 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
families = ["vdbcache"]
hosts = ["houdini"]
label = "Validate Output Node (VDB)"
+ actions = [SelectInvalidAction]
def process(self, instance):
- invalid = self.get_invalid(instance)
- if invalid:
- raise PublishValidationError(
- "Node connected to the output node is not" " of type VDB!",
- title=self.label
+ invalid_nodes, message = self.get_invalid_with_message(instance)
+ if invalid_nodes:
+
+ # instance_node is str, but output_node is hou.Node so we convert
+ output = instance.data.get("output_node")
+ output_path = output.path() if output else None
+
+ raise PublishXmlValidationError(
+ self,
+ "Invalid VDB content: {}".format(message),
+ formatting_data={
+ "message": message,
+ "rop_path": instance.data.get("instance_node"),
+ "sop_path": output_path
+ }
)
@classmethod
- def get_invalid(cls, instance):
+ def get_invalid_with_message(cls, instance):
- node = instance.data["output_node"]
+ node = instance.data.get("output_node")
if node is None:
- cls.log.error(
+ instance_node = instance.data.get("instance_node")
+ error = (
"SOP path is not correctly set on "
- "ROP node '%s'." % instance.data.get("instance_node")
+ "ROP node `{}`.".format(instance_node)
)
- return [instance]
+ return [hou.node(instance_node), error]
frame = instance.data.get("frameStart", 0)
- geometry = node.geometryAtFrame(frame)
+ geometry = get_geometry_at_frame(node, frame)
if geometry is None:
# No geometry data on this node, maybe the node hasn't cooked?
- cls.log.error(
- "SOP node has no geometry data. "
- "Is it cooked? %s" % node.path()
+ error = (
+ "SOP node `{}` has no geometry data. "
+ "Was it unable to cook?".format(node.path())
)
- return [node]
+ return [node, error]
- prims = geometry.prims()
- nr_of_prims = len(prims)
+ num_prims = geometry.intrinsicValue("primitivecount")
+ num_points = geometry.intrinsicValue("pointcount")
+ if num_prims == 0 and num_points == 0:
+ # Since we are only checking the first frame it doesn't mean there
+ # won't be VDB prims in a few frames. As such we'll assume for now
+ # the user knows what he or she is doing
+ cls.log.warning(
+ "SOP node `{}` has no primitives on start frame {}. "
+ "Validation is skipped and it is assumed elsewhere in the "
+ "frame range VDB prims and only VDB prims will exist."
+ "".format(node.path(), int(frame))
+ )
+ return [None, None]
- # All primitives must be hou.VDB
- invalid_prim = False
- for prim in prims:
- if not isinstance(prim, hou.VDB):
- cls.log.error("Found non-VDB primitive: %s" % prim)
- invalid_prim = True
- if invalid_prim:
- return [instance]
+ num_vdb_prims = geometry.countPrimType(hou.primType.VDB)
+ cls.log.debug("Detected {} VDB primitives".format(num_vdb_prims))
+ if num_prims != num_vdb_prims:
+ # There's at least one primitive that is not a VDB.
+ # Search them and report them to the artist.
+ prims = geometry.prims()
+ invalid_prims = [prim for prim in prims
+ if not isinstance(prim, hou.VDB)]
+ if invalid_prims:
+ # Log prim numbers as consecutive ranges so logging isn't very
+ # slow for large number of primitives
+ error = (
+ "Found non-VDB primitives for `{}`. "
+ "Primitive indices {} are not VDB primitives.".format(
+ node.path(),
+ ", ".join(group_consecutive_numbers(
+ prim.number() for prim in invalid_prims
+ ))
+ )
+ )
+ return [node, error]
- nr_of_points = len(geometry.points())
- if nr_of_points != nr_of_prims:
- cls.log.error("The number of primitives and points do not match")
- return [instance]
+ if num_points != num_vdb_prims:
+ # We have points unrelated to the VDB primitives.
+ error = (
+ "The number of primitives and points do not match in '{}'. "
+ "This likely means you have unconnected points, which we do "
+ "not allow in the VDB output.".format(node.path()))
+ return [node, error]
- for prim in prims:
- if prim.numVertices() != 1:
- cls.log.error("Found primitive with more than 1 vertex!")
- return [instance]
+ return [None, None]
+
+ @classmethod
+ def get_invalid(cls, instance):
+ nodes, _ = cls.get_invalid_with_message(instance)
+ return nodes