diff --git a/Changes.md b/Changes.md index 2534c825c6b..ca07af8878f 100644 --- a/Changes.md +++ b/Changes.md @@ -8,6 +8,11 @@ Features - USD Kind : When selecting, the first ancestor location with a `usd:kind` attribute matching the chosen list of USD Kind will ultimately be selected. USD's Kind Registry includes `Assembly`, `Component`, `Group`, `Model` and `SubComponent` by default and can be extended via USD startup scripts. - Shader Assignment : When selecting, the first ancestor location with a renderable and direct (not inherited) shader attribute will ultimately be selected. This can be used to select either surface or displacement shaders. +Improvements +------------ + +- EditScope : Added a summary of edits in the NodeEditor, with the ability to select the affected objects and quickly navigate to the processor nodes. + Fixes ----- @@ -21,6 +26,9 @@ API --- - SelectionTool : Added static `registerSelectMode()` method for registering a Python or C++ function that will modify a selected scene path location. Users can choose which mode is active when selecting. +- EditScopeUI : Added an API for customising the EditScope's NodeEditor with summaries for each processor : + - ProcessorWidget provides a base class for custom widgets, and a factory mechanism for registering them against processors. + - SimpleProcessorWidget provides a base class for widgets with a simple summary label and optional action links. 1.4.0.0b5 (relative to 1.4.0.0b4) ========= diff --git a/python/GafferSceneUI/EditScopeUI.py b/python/GafferSceneUI/EditScopeUI.py index 946d1ed6a28..d409c330090 100644 --- a/python/GafferSceneUI/EditScopeUI.py +++ b/python/GafferSceneUI/EditScopeUI.py @@ -41,6 +41,9 @@ import GafferScene import GafferSceneUI +# Pruning/Visibility hotkeys +# ========================== + def addPruningActions( editor ) : if isinstance( editor, GafferUI.Viewer ) : @@ -154,3 +157,149 @@ def __visibilityKeyPress( viewer, event ) : tweakPlug["value"].setValue( False ) return True + + +# Processor Widgets +# ================= + +class _SceneProcessorWidget( GafferUI.EditScopeUI.SimpleProcessorWidget ) : + + def _linkActivated( self, linkData ) : + + GafferSceneUI.ContextAlgo.setSelectedPaths( + self.processor().ancestor( Gaffer.ScriptNode ).context(), linkData + ) + +class __LocationEditsWidget( _SceneProcessorWidget ) : + + @staticmethod + def _summary( processor, linkCreator ) : + + # Get the locations being edited from the spreadsheet. + + canceller = Gaffer.Context.current().canceller() + activePathMatcher = IECore.PathMatcher() + disabledPathMatcher = IECore.PathMatcher() + for row in processor["edits"] : + IECore.Canceller.check( canceller ) + path = row["name"].getValue() + if not path : + continue + if row["enabled"].getValue() : + activePathMatcher.addPath( path ) + else : + disabledPathMatcher.addPath( path ) + + # Match those against the scene. + + activePaths = IECore.PathMatcher() + disabledPaths = IECore.PathMatcher() + GafferScene.SceneAlgo.matchingPaths( activePathMatcher, processor["in"], activePaths ) + GafferScene.SceneAlgo.matchingPaths( disabledPathMatcher, processor["in"], disabledPaths ) + + # Build a summary describing what we found. + + summaries = [] + if activePaths.size() : + activeLink = linkCreator( + "{} location{}".format( activePaths.size(), "s" if activePaths.size() > 1 else "" ), + activePaths + ) + summaries.append( f"edits on {activeLink}" ) + if disabledPaths.size() : + disabledLink = linkCreator( + "{} location{}".format( disabledPaths.size(), "s" if disabledPaths.size() > 1 else "" ), + disabledPaths + ) + summaries.append( f"disabled edits on {disabledLink}" ) + + if not summaries : + return "None" + + summaries[0] = summaries[0][0].upper() + summaries[0][1:] + return " and ".join( summaries ) + +GafferUI.EditScopeUI.ProcessorWidget.registerProcessorWidget( "AttributeEdits TransformEdits *LightEdits *SurfaceEdits", __LocationEditsWidget ) + +class __PruningEditsWidget( _SceneProcessorWidget ) : + + @staticmethod + def _summary( processor, linkCreator ) : + + paths = IECore.PathMatcher() + GafferScene.SceneAlgo.matchingPaths( processor["PathFilter"], processor["in"], paths ) + + if paths.isEmpty() : + return "None" + else : + link = linkCreator( + "{} location{}".format( paths.size(), "s" if paths.size() > 1 else "" ), + paths + ) + return f"{link} pruned" + +GafferUI.EditScopeUI.ProcessorWidget.registerProcessorWidget( "PruningEdits", __PruningEditsWidget ) + +class __RenderPassesWidget( GafferUI.EditScopeUI.SimpleProcessorWidget ) : + + @staticmethod + def _summary( processor, linkCreator ) : + + names = processor["names"].getValue() + return "{} render pass{} created".format( + len( names ) if names else "No", + "es" if len( names ) > 1 else "", + ) + +GafferUI.EditScopeUI.ProcessorWidget.registerProcessorWidget( "RenderPasses", __RenderPassesWidget ) + +class __RenderPassOptionEditsWidget( GafferUI.EditScopeUI.SimpleProcessorWidget ) : + + @staticmethod + def _summary( processor, linkCreator ) : + + enabledOptions = set() + enabledPasses = set() + disabledPasses = set() + for row in processor["edits"].children()[1:] : + renderPass = row["name"].getValue() + if not renderPass : + continue + if not row["enabled"].getValue() : + disabledPasses.add( renderPass ) + continue + + passEnabledOptions = { + cell["value"]["name"].getValue() + for cell in row["cells"] + if cell["value"]["enabled"].getValue() + } + + if passEnabledOptions : + enabledPasses.add( renderPass ) + enabledOptions = enabledOptions | passEnabledOptions + + summaries = [] + if enabledOptions : + summaries.append( + "edits to {} option{} in {} render pass{}".format( + len( enabledOptions ), "s" if len( enabledOptions ) > 1 else "", + len( enabledPasses ), "es" if len( enabledPasses ) > 1 else "", + ) + ) + + if disabledPasses : + summaries.append( + "disabled edits for {} render pass{}".format( + len( disabledPasses ), "es" if len( disabledPasses ) > 1 else "" + ) + ) + + if not summaries : + return "None" + + summaries[0] = summaries[0][0].upper() + summaries[0][1:] + return " and ".join( summaries ) + + +GafferUI.EditScopeUI.ProcessorWidget.registerProcessorWidget( "RenderPassOptionEdits", __RenderPassOptionEditsWidget ) diff --git a/python/GafferUI/EditScopeUI.py b/python/GafferUI/EditScopeUI.py index 275f86d5a41..d472104dc2e 100644 --- a/python/GafferUI/EditScopeUI.py +++ b/python/GafferUI/EditScopeUI.py @@ -45,6 +45,9 @@ import Gaffer import GafferUI +from GafferUI._StyleSheet import _styleColors +from Qt import QtGui + Gaffer.Metadata.registerNode( Gaffer.EditScope, @@ -75,6 +78,11 @@ "noduleLayout:customGadget:addButtonLeft:visible", lambda node : "in" in node, "noduleLayout:customGadget:addButtonRight:visible", lambda node : "in" in node, + # Add a custom widget for showing a summary of the processors within. + + "layout:customWidget:processors:widgetType", "GafferUI.EditScopeUI.__ProcessorsWidget", + "layout:customWidget:processors:section", "Edits", + plugs = { "in" : [ @@ -346,3 +354,226 @@ def __userNodes( editScope ) : nodes = Gaffer.Metadata.nodesWithMetadata( editScope, "editScope:includeInNavigationMenu" ) return [ n for n in nodes if n.ancestor( Gaffer.EditScope ).isSame( editScope ) ] + +# ProcessorWidget +# =============== + +class ProcessorWidget( GafferUI.Widget ) : + + def __init__( self, topLevelWidget, processor, **kw ) : + + GafferUI.Widget.__init__( self, topLevelWidget, **kw ) + + self.__processor = processor + + def processor( self ) : + + return self.__processor + + __widgetTypes = {} + @staticmethod + def registerProcessorWidget( processorType, widgetCreator ) : + + ProcessorWidget.__widgetTypes[processorType] = widgetCreator + + @staticmethod + def create( processor ) : + + processorType = Gaffer.Metadata.value( processor, "editScope:processorType" ) + creator = ProcessorWidget.__widgetTypes.get( processorType ) + if creator is None : + for name, candidate in ProcessorWidget.__widgetTypes.items() : + if IECore.StringAlgo.matchMultiple( processorType, name ) : + creator = candidate + break + + if creator is not None : + return creator( processor ) + + return None + +# SimpleProcessorWidget +# ===================== + +## Base class for creating simple summaries of Processors, including links +class SimpleProcessorWidget( ProcessorWidget ) : + + def __init__( self, processor, **kw ) : + + self.__column = GafferUI.ListContainer( spacing = 4 ) + ProcessorWidget.__init__( self, self.__column, processor, **kw ) + + with self.__column : + with GafferUI.ListContainer( orientation = GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + label = GafferUI.NameLabel( processor ) + label.setFormatter( lambda g : "

{}Show" ) + showLabel.linkActivatedSignal().connect( Gaffer.WeakMethod( self.__show ), scoped = False ) + GafferUI.Divider() + + ## Called to retrieve the text for the summary label, so must be overridden + # by derived classes. Use `linkCreator( text, data )` to create an HTML link + # to include in the summary. When the link is clicked, `_linkActivated( data )` + # will be called. + # + # > Note : This is called on a background thread to avoid locking + # > the UI, so it is static to avoid the possibility of unsafe + # > access to UI elements. + @staticmethod + def _summary( processor, linkCreator ) : + + raise NotImplementedError + + ## Called when a link within the summary is clicked. + def _linkActivated( self, linkData ) : + + raise NotImplementedError + + def __show( self, *unused ) : + + GafferUI.NodeEditor.acquire( self.processor() ) + +## Helper class for associating arbitrary data with HTML links. +class _LinkCreator : + + def __init__( self ) : + + self.__linkData = [] + + def __call__( self, text, data ) : + + index = len( self.__linkData ) + self.__linkData.append( data ) + textColor = QtGui.QColor( *_styleColors["foregroundInfo"] ).name() + + return f"{text}" + + def linkData( self, link ) : + + index = int( link.rpartition( "/" )[2] ) + return self.__linkData[index] + +# Factory for PlugValueWidget subclasses for showing the summary. We want to use PlugValueWidget +# for this because it handles all the details of background updates for us. But we need to make +# a unique subclass for each `summaryFunction` because `PlugValueWidget._valuesForUpdate()` is +# static. +__summaryWidgetClasses = {} +def _acquireSummaryWidgetClass( summaryFunction ) : + + global __summaryWidgetClasses + if summaryFunction in __summaryWidgetClasses : + return __summaryWidgetClasses[summaryFunction] + + class _SummaryPlugValueWidget( GafferUI.PlugValueWidget ) : + + def __init__( self, plug, **kw ) : + + row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) + GafferUI.PlugValueWidget.__init__( self, row, { plug }, **kw ) + + with row : + self.__errorImage = GafferUI.Image( "errorSmall.png" ) + self.__label = GafferUI.Label() + self.__label.linkActivatedSignal().connect( Gaffer.WeakMethod( self.__linkActivated ), scoped = False ) + GafferUI.Spacer( size = imath.V2i( 1, 20 ) ) + self.__busyWidget = GafferUI.BusyWidget( size = 20 ) + + @staticmethod + def _valuesForUpdate( plugs, auxiliaryPlugs ) : + + assert( len( plugs ) == 1 ) + + links = _LinkCreator() + summary = summaryFunction( next( iter( plugs ) ).node(), links ) + + return [ { "summary" : summary, "links" : links } ] + + def _updateFromValues( self, values, exception ) : + + self.__busyWidget.setVisible( not values and exception is None ) + + self.__errorImage.setVisible( exception is not None ) + self.__errorImage.setToolTip( str( exception ) if exception is not None else "" ) + + if values : + self.__label.setText( + "{summary}".format( + textColor = QtGui.QColor( *_styleColors["foreground"] ).name(), + summary = values[0]["summary"] if len( values ) else "" + ) + ) + self.__links = values[0]["links"] + + def __linkActivated( self, label, link ) : + + self.ancestor( SimpleProcessorWidget )._linkActivated( self.__links.linkData( link ) ) + + __summaryWidgetClasses[summaryFunction] = _SummaryPlugValueWidget + return _SummaryPlugValueWidget + +# __ProcessorsWidget +# ================== + +class __ProcessorsWidget( GafferUI.Widget ) : + + def __init__( self, editScope, **kw ) : + + self.__column = GafferUI.ListContainer( spacing = 4 ) + GafferUI.Widget.__init__( self, self.__column, **kw ) + + self.__editScope = editScope + self.__processorWidgets = {} + + editScope.childAddedSignal().connect( Gaffer.WeakMethod( self.__editScopeChildAdded ), scoped = False ) + editScope.childRemovedSignal().connect( Gaffer.WeakMethod( self.__editScopeChildRemoved ), scoped = False ) + + self.__update() + + def __editScopeChildAdded( self, editScope, child ) : + + if Gaffer.Metadata.value( child, "editScope:processorType" ) : + self.__update() + + def __editScopeChildRemoved( self, editScope, child ) : + + if Gaffer.Metadata.value( child, "editScope:processorType" ) : + self.__update() + + @GafferUI.LazyMethod() + def __update( self ) : + + # Get rid of any widgets we don't need + + processors = set( self.__editScope.processors() ) + self.__processorWidgets = { + p : w for p, w in self.__processorWidgets.items() + if p in processors + } + + # Make sure we have a widget for all processors + + for processor in processors : + if processor in self.__processorWidgets : + continue + widget = ProcessorWidget.create( processor ) + self.__processorWidgets[processor] = widget + + # Update the layout + + widgets = [ w for w in self.__processorWidgets.values() if w is not None ] + widgets = sorted( widgets, key = lambda w : w.processor().getName() ) + + if not widgets : + textColor = QtGui.QColor( *_styleColors["foregroundFaded"] ).name() + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) as row : + GafferUI.Image( "infoSmall.png" ) + GafferUI.Label( f"No edits created yet" ) + widgets.append( row ) + + self.__column[:] = widgets