diff --git a/lib/mayaUsd/resources/ae/CMakeLists.txt b/lib/mayaUsd/resources/ae/CMakeLists.txt index d92cca6ff..37a3ae9b1 100644 --- a/lib/mayaUsd/resources/ae/CMakeLists.txt +++ b/lib/mayaUsd/resources/ae/CMakeLists.txt @@ -43,7 +43,8 @@ install(FILES __init__.py DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/python/${PROJE if(MAYA_APP_VERSION VERSION_GREATER_EQUAL 2023) foreach(_SUBDIR ${MAYAUSD_AE_TEMPLATES}) install(FILES - ${_SUBDIR}/lightCustomControl.py + ${_SUBDIR}/collectionCustomControl.py + ${_SUBDIR}/collectionMayaHost.py DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/python/ufe_ae/usd/nodes/${_SUBDIR} ) endforeach() diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/expressionWidget.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/expressionWidget.py index 0961af022..02f492489 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/expressionWidget.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/expressionWidget.py @@ -51,8 +51,7 @@ def __init__(self, data: CollectionData, parent: QWidget, expressionChangedCallb def _onDataChanged(self): usdExpressionAttr = self._collData.getMembershipExpression() - if usdExpressionAttr != None: - self._expressionText.setPlainText(usdExpressionAttr) + self._expressionText.setPlainText(usdExpressionAttr or '') def submitExpression(self): self._collData.setMembershipExpression(self._expressionText.toPlainText()) diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/widget.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/widget.py index 40eafceba..d06891d19 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/widget.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/collection/widget.py @@ -1,18 +1,19 @@ from .includeExcludeWidget import IncludeExcludeWidget from .expressionWidget import ExpressionWidget from ..common.theme import Theme +from ..common.host import Host from ..usdData.usdCollectionData import UsdCollectionData try: - from PySide6.QtCore import QEvent, QObject, Qt, Slot # type: ignore + from PySide6.QtCore import Qt # type: ignore from PySide6.QtGui import QIcon, QWheelEvent # type: ignore from PySide6.QtWidgets import QTabBar, QTabWidget, QVBoxLayout, QWidget # type: ignore except ImportError: - from PySide2.QtCore import QEvent, QObject, Qt, Slot # type: ignore + from PySide2.QtCore import Qt # type: ignore from PySide2.QtGui import QIcon, QWheelEvent # type: ignore from PySide2.QtWidgets import QTabBar, QTabWidget, QVBoxLayout, QWidget # type: ignore -from pxr import Usd, Tf +from pxr import Usd class NonScrollingTabBar(QTabBar): @@ -38,7 +39,7 @@ def __init__( self._collection: Usd.CollectionAPI = collection self._prim: Usd.Prim = prim - self._collData = UsdCollectionData(prim, collection) + self._collData = Host.instance().createCollectionData(prim, collection) mainLayout = QVBoxLayout() mainLayout.setContentsMargins(0, 0, 0, 0) diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/host.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/host.py index a33c8e852..524d683c3 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/host.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/common/host.py @@ -10,21 +10,61 @@ def __init__(self): @classmethod def instance(cls): + ''' + Retrieve the DCC-specific instance of this Host interface. + ''' if cls._instance is None: cls._instance = cls.__new__(cls) return cls._instance @classmethod def injectInstance(cls, host): + ''' + Set the DCC-specific instance of this Host interface. + ''' cls._instance = host @property def canPick(self) -> bool: + ''' + Verify if the DCC-specific instance of this Host interface can pick USD prims. + ''' return False @property def canDrop(self) -> bool: + ''' + Verify if the DCC-specific instance of this Host interface can drag-and-drop USD prims. + ''' return True def pick(self, stage: Usd.Stage, *, dialogTitle: str = "") -> Sequence[Usd.Prim]: - return None \ No newline at end of file + ''' + Pick USD prims. + + Must be implemented by the DCC-specific sub-class of this Host interface if the DCC + supports picking USD prims. + ''' + return None + + def createCollectionData(self, prim: Usd.Prim, collection: Usd.CollectionAPI): + ''' + Create the data to hold a USD collection. + + Can be implemented by the DCC-specific sub-class of this Host interface + to return a DCC-specific implementation of the data, to support undo and + redo, for example. + ''' + from ..usdData.usdCollectionData import UsdCollectionData + return UsdCollectionData(prim, collection) + + def createStringListData(self, collection: Usd.CollectionAPI, isInclude: bool): + ''' + Create the data to hold a list of included or excluded items. + + Can be implemented by the DCC-specific sub-class of this Host interface + to return a DCC-specific implementation of the data, to support undo and + redo, for example. + ''' + from ..usdData.usdCollectionStringListData import CollectionStringListData + return CollectionStringListData(collection, isInclude) diff --git a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/usdData/usdCollectionData.py b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/usdData/usdCollectionData.py index 7af5ea7bb..0544bb982 100644 --- a/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/usdData/usdCollectionData.py +++ b/lib/mayaUsd/resources/ae/usd-shared-components/src/python/usdSharedComponents/usdData/usdCollectionData.py @@ -1,15 +1,15 @@ from typing import AnyStr, Sequence from ..data.collectionData import CollectionData from .usdCollectionStringListData import CollectionStringListData -from ..common.host import Host from pxr import Sdf, Tf, Usd class UsdCollectionData(CollectionData): def __init__(self, prim: Usd.Prim, collection: Usd.CollectionAPI): super().__init__() - self._includes = CollectionStringListData(collection, True) - self._excludes = CollectionStringListData(collection, False) + from ..common.host import Host + self._includes = Host.instance().createStringListData(collection, True) + self._excludes = Host.instance().createStringListData(collection, False) self._noticeKey = None self.setCollection(prim, collection) diff --git a/lib/mayaUsd/resources/ae/usdschemabase/ae_template.py b/lib/mayaUsd/resources/ae/usdschemabase/ae_template.py index 54e4f389d..90ab7af61 100644 --- a/lib/mayaUsd/resources/ae/usdschemabase/ae_template.py +++ b/lib/mayaUsd/resources/ae/usdschemabase/ae_template.py @@ -23,10 +23,10 @@ from .metadataCustomControl import MetadataCustomControl from .observers import UfeAttributesObserver, UfeConnectionChangedObserver, UsdNoticeListener try: - from .lightCustomControl import LightLinkingCustomControl - lightLinkingSupported = True + from .collectionCustomControl import CollectionCustomControl + collectionsSupported = True except: - lightLinkingSupported = False + collectionsSupported = False import collections import fnmatch @@ -272,8 +272,8 @@ def __init__(self, ufeSceneItem): pass _controlCreators = [ConnectionsCustomControl.creator, ArrayCustomControl.creator, ImageCustomControl.creator, defaultControlCreator] - if lightLinkingSupported: - _controlCreators.insert(0, LightLinkingCustomControl.creator) + if collectionsSupported: + _controlCreators.insert(0, CollectionCustomControl.creator) @staticmethod def prependControlCreator(controlCreator): diff --git a/lib/mayaUsd/resources/ae/usdschemabase/lightCustomControl.py b/lib/mayaUsd/resources/ae/usdschemabase/collectionCustomControl.py similarity index 82% rename from lib/mayaUsd/resources/ae/usdschemabase/lightCustomControl.py rename to lib/mayaUsd/resources/ae/usdschemabase/collectionCustomControl.py index 619208d89..97d65752f 100644 --- a/lib/mayaUsd/resources/ae/usdschemabase/lightCustomControl.py +++ b/lib/mayaUsd/resources/ae/usdschemabase/collectionCustomControl.py @@ -1,20 +1,12 @@ import maya.cmds as cmds from pxr import Usd -from typing import Sequence from usd_shared_components.collection.widget import CollectionWidget # type: ignore from usd_shared_components.common.host import Host # type: ignore -class MayaHost(Host): - '''Class to host and override maya specific functions for the collection API.''' - def __init__(self): - pass - - def pick(self, stage: Usd.Stage, *, dialogTitle: str = "") -> Sequence[Usd.Prim]: - return [] # nothing to do yet - +from .collectionMayaHost import MayaHost -class LightLinkingCustomControl(object): +class CollectionCustomControl(object): '''Custom control for the light linking data we want to display.''' @staticmethod @@ -41,16 +33,16 @@ def creator(aeTemplate, attrName): ''' If the attribute is a collection attribute then create a section to edit it. ''' - if LightLinkingCustomControl.isCollectionAttribute(aeTemplate, attrName): + if CollectionCustomControl.isCollectionAttribute(aeTemplate, attrName): attrName, instanceName = attrName.split(':') - return LightLinkingCustomControl(aeTemplate.item, aeTemplate.prim, attrName, instanceName, aeTemplate.useNiceName) + return CollectionCustomControl(aeTemplate.item, aeTemplate.prim, attrName, instanceName, aeTemplate.useNiceName) else: return None def __init__(self, item, prim, attrName, instanceName, useNiceName): # In Maya 2022.1 we need to hold onto the Ufe SceneItem to make # sure it doesn't go stale. This is not needed in latest Maya. - super(LightLinkingCustomControl, self).__init__() + super(CollectionCustomControl, self).__init__() mayaVer = '%s.%s' % (cmds.about(majorVersion=True), cmds.about(minorVersion=True)) self.item = item if mayaVer == '2022.1' else None self.attrName = attrName diff --git a/lib/mayaUsd/resources/ae/usdschemabase/collectionMayaHost.py b/lib/mayaUsd/resources/ae/usdschemabase/collectionMayaHost.py new file mode 100644 index 000000000..20b6772b7 --- /dev/null +++ b/lib/mayaUsd/resources/ae/usdschemabase/collectionMayaHost.py @@ -0,0 +1,331 @@ + +from usd_shared_components.common.host import Host +from usd_shared_components.usdData.usdCollectionData import UsdCollectionData +from usd_shared_components.usdData.usdCollectionStringListData import CollectionStringListData + +from maya.api.OpenMaya import MPxCommand, MFnPlugin, MGlobal, MSyntax, MArgDatabase +import mayaUsd.lib +import maya.mel as mel + +from pxr import Usd +from typing import AnyStr, Sequence, Tuple + +class _UndoItemHolder: + ''' + Hold USD undo items temporarily to transfer them between the USD undo block context + and the undoable Maya command that will hold the USD undo item to be undone and redone. + + We need a holder because there might be multiple nested undo contexts in flight at + the same time due to Qt signal or USD notifications triggering UI updates. We need + to identify which undo context corresponds to which Maya command. We do this by + assigning a unique ID to each one, so they can know which undo item to transfer. + + The undo context creates the item and records the ID and then pass the ID to the + Maya command so that it can retrieve the undo item and clean up this holder. + ''' + _undoId = 0 + _undoItems = {} + + @classmethod + def createUndoItem(cls) -> Tuple[int, mayaUsd.lib.UsdUndoableItem]: + ''' + Create a new unique ID and an undo item. Returns both. + ''' + id = cls._undoId + cls._undoId += 1 + + undoItem = mayaUsd.lib.UsdUndoableItem() + + cls._undoItems[id] = undoItem + + return (id, undoItem) + + @classmethod + def getUndoItem(cls, id: int) -> mayaUsd.lib.UsdUndoableItem: + ''' + Retrieve the undo item corresponding to the ID, if any. + ''' + if id not in cls._undoItems: + return None + return cls._undoItems[id] + + @classmethod + def removeUndoItem(cls, id: int) -> None: + ''' + Remove (forget) the given ID. + ''' + if id not in cls._undoItems: + return None + del cls._undoItems[id] + + +class _UsdUndoBlockContext: + ''' + Python context manager (IOW, a class that can be used with Python's `with` keyword) + that captures USD changes in a USD undo block so the USD changes can be transferred + into a Maya command that can be undone and redone. + + The transfer is done via the _UndoItemHolder class above. + ''' + def __init__(self, cmd): + ''' + Create a USD undo block context manager with the Maya command that will + be invoked to transfer the USD undo items. + ''' + super(_UsdUndoBlockContext, self).__init__() + self._id = -1 + self._undoItem = None + self._cmd = cmd + self._usdUndoBlock = None + + # Python context manager API / special methods. + + def __enter__(self): + ''' + Create the undo item and its ID that will hold the USD edits + and the USD undo block that will capture these undo items. + ''' + # Note: protect against bad usage and calling __enter__ multiple times. + if self._usdUndoBlock: + self._usdUndoBlock.__exit__(None, None, None) + + self._id, self._undoItem = _UndoItemHolder.createUndoItem() + self._usdUndoBlock = mayaUsd.lib.UsdUndoBlock(self._undoItem) + self._usdUndoBlock.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + ''' + Ends the capture of undo items and call the Maya command that will + transfer them from the holder class and ultimately hold them. + ''' + # Note: protect against bad usage and calling __exit__ before __enter__. + if not self._usdUndoBlock: + return + + # Call __exit__ to transfer all USD undo tracking to the self._undoItem + self._usdUndoBlock.__exit__(exc_type, exc_val, exc_tb) + self._usdUndoBlock = None + + # Call a Maya command in which the undo items can be transferred. + # That Maya command will be added to the undo stack in Maya, allowing + # undo and redo. + # + # Note: we need to use MEL to call the command so that it shows up properly + # in the undo UI. We we invoke it in Python with maya.cmds.abc + # it shows up as an empty string in the undo UI. + mel.eval(self._cmd + ' ' + str(self._id)) + self._id = -1 + self._undoItem = None + + +class _UsdUndoBlockCommand(MPxCommand): + ''' + Custom Maya undoable command that receives the USD changes from the + USD undo block context manager class above via the undo item holder + class above. + + This command is only meant to transfer USD edits from a USD undo block + to a Maya command with a nice name. Its execution does not do work, but + transfer work already done. It is not meant to be usable in scripts alone + by itself but only via the _UsdUndoBlockContext class above. + + We receive the undo item ID as an argument and transfer the corresponding + undo item from the _UndoItemHolder into this command to be undoable and + redoable afterward. + + This command is meant to be sub-classed so that the name of the command + in the undo stack is nice and relevant. + ''' + + def __init__(self): + super(_UsdUndoBlockCommand, self).__init__() + self._undoItem = None + + # MPxCommand command implementation. + + @classmethod + def creator(cls): + return cls() + + @classmethod + def createSyntax(cls): + ''' + The command receives a single argument: the undo item ID to transfer. + ''' + syntax = MSyntax() + syntax.addArg(syntax.kLong) + return syntax + + def isUndoable(self): + return self._undoItem is not None + + def doIt(self, args): + ''' + Transfer the undo item corresponding to the given ID and remove them + from the undo item holder. This command is their final resting place. + ''' + argDB = MArgDatabase(self.createSyntax(), args) + id = argDB.commandArgumentInt(0) + self._undoItem = _UndoItemHolder.getUndoItem(id) + _UndoItemHolder.removeUndoItem(id) + + def undoIt(self): + if self._undoItem: + self._undoItem.undo() + + def redoIt(self): + if self._undoItem: + self._undoItem.redo() + + +class _SetIncludeAllCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionSetIncludeAll' + def __init__(self): + super().__init__() + + +class _RemoveAllIncludeExcludeCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionRemoveAll' + def __init__(self): + super().__init__() + + +class _SetExansionRuleCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionSetExpansionRule' + def __init__(self): + super().__init__() + + +class _SetMembershipExpressionCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionSetMembershipExpression' + def __init__(self): + super().__init__() + + +class _AddItemsCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionAddItems' + def __init__(self): + super().__init__() + + +class _RemoveItemsCommand(_UsdUndoBlockCommand): + commandName = 'usdCollectionRemoveItems' + def __init__(self): + super().__init__() + + +_allCommandClasses = [ + _SetIncludeAllCommand, + _RemoveAllIncludeExcludeCommand, + _SetExansionRuleCommand, + _SetMembershipExpressionCommand, + _AddItemsCommand, + _RemoveItemsCommand] + +def registerCommands(pluginName): + ''' + Register the collection commands so that they can be invoked by + the undo context manager class above and be in the Maya undo stack. + ''' + plugin = MFnPlugin.findPlugin(pluginName) + if not plugin: + MGlobal.displayWarning('Cannot register collection commands') + return + + plugin = MFnPlugin(plugin) + + for cls in _allCommandClasses: + try: + plugin.registerCommand(cls.commandName, cls.creator, cls.createSyntax) + except Exception as ex: + print(ex) + +def deregisterCommands(pluginName): + ''' + Unregister the collection commands. + ''' + plugin = MFnPlugin.findPlugin(pluginName) + if not plugin: + MGlobal.displayWarning('Cannot deregister collection commands') + return + + plugin = MFnPlugin(plugin) + + for cls in _allCommandClasses: + try: + plugin.deregisterCommand(cls.commandName) + except Exception as ex: + print(ex) + + +class MayaCollectionData(UsdCollectionData): + ''' + Maya-specfic USD collection data, to add undo/redo support. + ''' + def __init__(self, prim: Usd.Prim, collection: Usd.CollectionAPI): + super().__init__(prim, collection) + + # Include and exclude + + def setIncludeAll(self, state: bool): + with _UsdUndoBlockContext(_SetIncludeAllCommand.commandName): + super().setIncludeAll(state) + + def removeAllIncludeExclude(self): + with _UsdUndoBlockContext(_RemoveAllIncludeExcludeCommand.commandName): + super().removeAllIncludeExclude() + + # Expression + + def setExpansionRule(self, rule): + with _UsdUndoBlockContext(_SetExansionRuleCommand.commandName): + super().setExpansionRule(rule) + + def setMembershipExpression(self, textExpression: AnyStr): + with _UsdUndoBlockContext(_SetMembershipExpressionCommand.commandName): + super().setMembershipExpression(textExpression) + + +class MayaStringListData(CollectionStringListData): + ''' + Maya-specfic string list data, to add undo/redo support. + ''' + def __init__(self, collection, isInclude: bool): + super().__init__(collection, isInclude) + + def addStrings(self, items: Sequence[AnyStr]): + ''' + Add the given strings to the model. + ''' + with _UsdUndoBlockContext(_AddItemsCommand.commandName): + super().addStrings(items) + + def removeStrings(self, items: Sequence[AnyStr]): + ''' + Remove the given strings from the model. + ''' + with _UsdUndoBlockContext(_RemoveItemsCommand.commandName): + super().removeStrings(items) + + +class MayaHost(Host): + '''Class to host and override Maya-specific functions for the collection API.''' + def __init__(self): + pass + + @property + def canPick(self) -> bool: + return False + + @property + def canDrop(self) -> bool: + return True + + def pick(self, stage: Usd.Stage, *, dialogTitle: str = "") -> Sequence[Usd.Prim]: + return [] # nothing to do yet + + def createCollectionData(self, prim: Usd.Prim, collection: Usd.CollectionAPI): + return MayaCollectionData(prim, collection) + + def createStringListData(self, collection: Usd.CollectionAPI, isInclude: bool): + return MayaStringListData(collection, isInclude) diff --git a/plugin/adsk/plugin/plugin.cpp b/plugin/adsk/plugin/plugin.cpp index 23b83d6ac..9046b84c7 100644 --- a/plugin/adsk/plugin/plugin.cpp +++ b/plugin/adsk/plugin/plugin.cpp @@ -189,6 +189,13 @@ MStatus initializePlugin(MObject obj) MayaUsd::MayaUsdUndoBlockCmd::commandName, MayaUsd::MayaUsdUndoBlockCmd::creator); CHECK_MSTATUS(status); + MGlobal::executePythonCommand( + "try:\n" + " from ufe_ae.usd.nodes.usdschemabase import collectionMayaHost\n" + " collectionMayaHost.registerCommands('mayaUsdPlugin')\n" + "except:\n" + " pass\n"); + status = MayaUsdProxyShapePlugin::initialize(plugin); CHECK_MSTATUS(status); @@ -380,6 +387,13 @@ MStatus uninitializePlugin(MObject obj) status = plugin.deregisterCommand(MayaUsd::MayaUsdUndoBlockCmd::commandName); CHECK_MSTATUS(status); + MGlobal::executePythonCommand( + "try:\n" + " from ufe_ae.usd.nodes.usdschemabase import collectionMayaHost\n" + " collectionMayaHost.deregisterCommands('mayaUsdPlugin')\n" + "except:\n" + " pass\n"); + // Deregister from file path editor status = MGlobal::executeCommand("filePathEditor -deregisterType \"mayaUsdProxyShape.filePath\" "