diff --git a/Changes.md b/Changes.md index cd3eb97dade..f5216f25e9b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,11 +1,27 @@ 1.7.x.x (relative to 1.7.0.0a10) ======= +Features +-------- + +- LightLinkingEditor : Added a new editor UI for inspecting and editing light links. + Fixes ----- - MenuBar : Made the main window menu extension button more visible. This button is shown when the window is not wide enough to show all menu items. - LightUI : Fixed `nodule:type` metadata lookups. Previously these ignored metadata registered to `light:{name}:{parameterName}`. +- LightEditor : Fixed bug preventing the "Copy Path" menu item from appearing when the current selection contained locations not shown in the LightEditor. +- PathListingWidget : Paths dragged from a PathListingWidget now preserve the order in which they are displayed. +- SetExpressionAlgo : Fixed invalid set expressions returned by `exclude()` when the set expression to be excluded contains only whitespace [^1]. +- PlugLayout : `:width` metadata is now correctly reapplied to widgets with labels when a PlugLayout is rebuilt. + +API +--- + +- SetExpressionAlgo : Added `remove` [^1]. + +[^1]: Improvement to a feature introduced in `1.7.0.0a1`, so should be omitted from final `1.7.0.0` release notes. 1.7.0.0a10 (relative to 1.7.0.0a9) ========== diff --git a/include/Gaffer/SetExpressionAlgo.h b/include/Gaffer/SetExpressionAlgo.h old mode 100644 new mode 100755 index 843732c2c3a..7584a6f7096 --- a/include/Gaffer/SetExpressionAlgo.h +++ b/include/Gaffer/SetExpressionAlgo.h @@ -91,5 +91,11 @@ GAFFER_API std::string include( const std::string &setExpression, const std::str /// the result simplified. Returns "" if `setExpression` is empty or would simplify /// to an empty expression. GAFFER_API std::string exclude( const std::string &setExpression, const std::string &exclusions ); +/// Returns a set expression with `removals` removed from `setExpression` and the +/// result simplified. Unlike `exclude()`, which both removes and subtracts `exclusions`, +/// `remove()` only removes the matching operations. For example `remove( "A B C", "B" )` +/// returns `"A C"` rather than `"A C - B"`. Returns "" if `setExpression` is empty +/// or would simplify to an empty expression. +GAFFER_API std::string remove( const std::string &setExpression, const std::string &removals ); } // namespace Gaffer::SetExpressionAlgo diff --git a/python/GafferSceneUI/LightLinkingEditor.py b/python/GafferSceneUI/LightLinkingEditor.py new file mode 100755 index 00000000000..353bc6b87cd --- /dev/null +++ b/python/GafferSceneUI/LightLinkingEditor.py @@ -0,0 +1,1148 @@ +########################################################################## +# +# Copyright (c) 2026, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import functools +import imath +import inspect + +import IECore + +import Gaffer +import GafferUI +import GafferScene +import GafferSceneUI + +from Qt import QtWidgets + +from . import _GafferSceneUI + +class LightLinkingEditor( GafferSceneUI.SceneEditor ) : + + class Settings( GafferSceneUI.SceneEditor.Settings ) : + + def __init__( self ) : + + GafferSceneUI.SceneEditor.Settings.__init__( self, withHierarchyFilter = True ) + + self["editScope"] = Gaffer.Plug() + + self["mode"] = Gaffer.StringPlug( defaultValue = "append" ) + self["attribute"] = Gaffer.StringPlug( defaultValue = "linkedLights" ) + + self["setsFilter"] = Gaffer.StringPlug() + self["onlyLinkedToSelection"] = Gaffer.BoolPlug() + + self["__lightsSetFilter"] = GafferScene.SetFilter() + self["__lightsSetFilter"]["setExpression"].setValue( "__lights" ) + + self["__isolateLights"] = GafferScene.Isolate() + self["__isolateLights"]["in"].setInput( self["__adaptedIn"] ) + self["__isolateLights"]["filter"].setInput( self["__lightsSetFilter"]["out"] ) + + self["__linkedLightsSetFilter"] = GafferScene.SetFilter() + + self["__isolateLightsLinkedToSelection"] = GafferScene.Isolate() + self["__isolateLightsLinkedToSelection"]["in"].setInput( self["__isolateLights"]["out"] ) + self["__isolateLightsLinkedToSelection"]["filter"].setInput( self["__linkedLightsSetFilter"]["out"] ) + self["__isolateLightsLinkedToSelection"]["enabled"].setInput( self["onlyLinkedToSelection"] ) + + self["__lightsAndFiltersSetFilter"] = GafferScene.SetFilter() + self["__lightsAndFiltersSetFilter"]["setExpression"].setValue( "__lights __lightFilters" ) + + self["__filteredObjects"] = GafferScene.Prune() + self["__filteredObjects"]["in"].setInput( self["__filteredIn"] ) + self["__filteredObjects"]["filter"].setInput( self["__lightsAndFiltersSetFilter"]["out"] ) + + self["__deleteContextVariables"] = Gaffer.DeleteContextVariables() + self["__deleteContextVariables"].setup( self["__adaptedIn"] ) + self["__deleteContextVariables"]["in"].setInput( self["__adaptedIn"] ) + self["__deleteContextVariables"]["variables"].setValue( "__lightLinkingEditorCollect:value __lightLinkingEditorCollect:index" ) + + self["__isolateObjects"] = GafferScene.Prune() + self["__isolateObjects"]["in"].setInput( self["__deleteContextVariables"]["out"] ) + self["__isolateObjects"]["filter"].setInput( self["__lightsAndFiltersSetFilter"]["out"] ) + + self["__lightsHierarchyFilter"] = GafferSceneUI.SceneEditor._HierarchyFilter() + self["__lightsHierarchyFilter"]["in"].setInput( self["__isolateLightsLinkedToSelection"]["out"] ) + + self["__lightFiltersSetFilter"] = GafferScene.SetFilter() + self["__lightFiltersSetFilter"]["setExpression"].setValue( "__lightFilters" ) + + self["__isolateLightFilters"] = GafferScene.Isolate() + self["__isolateLightFilters"]["in"].setInput( self["__deleteContextVariables"]["out"] ) + self["__isolateLightFilters"]["filter"].setInput( self["__lightFiltersSetFilter"]["out"] ) + + self["__lightFilterHierarchyFilter"] = GafferSceneUI.SceneEditor._HierarchyFilter() + self["__lightFilterHierarchyFilter"]["in"].setInput( self["__isolateLightFilters"]["out"] ) + + Gaffer.PlugAlgo.promoteWithName( self["__lightsHierarchyFilter"]["filter"], "lightsFilter" ) + Gaffer.PlugAlgo.promoteWithName( self["__lightsHierarchyFilter"]["setFilter"], "lightsSetFilter" ) + + Gaffer.PlugAlgo.promoteWithName( self["__lightFilterHierarchyFilter"]["filter"], "lightFiltersFilter" ) + Gaffer.PlugAlgo.promoteWithName( self["__lightFilterHierarchyFilter"]["setFilter"], "lightFiltersSetFilter" ) + + self["__linkedLightsAttributeQuery"] = GafferScene.AttributeQuery() + self["__linkedLightsAttributeQuery"].setup( Gaffer.StringPlug() ) + self["__linkedLightsAttributeQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__linkedLightsAttributeQuery"]["inherit"].setValue( True ) + self["__linkedLightsAttributeQuery"]["attribute"].setValue( "linkedLights" ) + self["__linkedLightsAttributeQuery"]["default"].setValue( "defaultLights" ) + self["__linkedLightsAttributeQuery"]["scene"].setInput( self["__deleteContextVariables"]["out"] ) + + self["__excludedLightsAttributeQuery"] = GafferScene.AttributeQuery() + self["__excludedLightsAttributeQuery"].setup( Gaffer.StringPlug() ) + self["__excludedLightsAttributeQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__excludedLightsAttributeQuery"]["inherit"].setValue( True ) + self["__excludedLightsAttributeQuery"]["attribute"].setValue( "linkedLights:exclusions" ) + self["__excludedLightsAttributeQuery"]["scene"].setInput( self["__deleteContextVariables"]["out"] ) + + self["__filteredLightsAttributeQuery"] = GafferScene.AttributeQuery() + self["__filteredLightsAttributeQuery"].setup( Gaffer.StringPlug() ) + self["__filteredLightsAttributeQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__filteredLightsAttributeQuery"]["inherit"].setValue( True ) + self["__filteredLightsAttributeQuery"]["attribute"].setValue( "filteredLights" ) + self["__filteredLightsAttributeQuery"]["scene"].setInput( self["__deleteContextVariables"]["out"] ) + + self["__excludedFilteredLightsAttributeQuery"] = GafferScene.AttributeQuery() + self["__excludedFilteredLightsAttributeQuery"].setup( Gaffer.StringPlug() ) + self["__excludedFilteredLightsAttributeQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__excludedFilteredLightsAttributeQuery"]["inherit"].setValue( True ) + self["__excludedFilteredLightsAttributeQuery"]["attribute"].setValue( "filteredLights:exclusions" ) + self["__excludedFilteredLightsAttributeQuery"]["scene"].setInput( self["__deleteContextVariables"]["out"] ) + + self["__objectsExistenceQuery"] = GafferScene.ExistenceQuery() + self["__objectsExistenceQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__objectsExistenceQuery"]["scene"].setInput( self["__isolateObjects"]["out"] ) + + self["__lightFiltersExistenceQuery"] = GafferScene.ExistenceQuery() + self["__lightFiltersExistenceQuery"]["location"].setValue( "${__lightLinkingEditorCollect:value}" ) + self["__lightFiltersExistenceQuery"]["scene"].setInput( self["__isolateLightFilters"]["out"] ) + + self["__inclusionsSwitch"] = Gaffer.Switch() + self["__inclusionsSwitch"].setup( Gaffer.StringPlug() ) + self["__inclusionsSwitch"]["in"].resize( 2 ) + self["__inclusionsSwitch"]["in"][0].setInput( self["__filteredLightsAttributeQuery"]["value"] ) + self["__inclusionsSwitch"]["in"][1].setInput( self["__linkedLightsAttributeQuery"]["value"] ) + self["__inclusionsSwitch"]["index"].setInput( self["__objectsExistenceQuery"]["exists"] ) + + self["__exclusionsSwitch"] = Gaffer.Switch() + self["__exclusionsSwitch"].setup( Gaffer.StringPlug() ) + self["__exclusionsSwitch"]["in"].resize( 2 ) + self["__exclusionsSwitch"]["in"][0].setInput( self["__excludedFilteredLightsAttributeQuery"]["value"] ) + self["__exclusionsSwitch"]["in"][1].setInput( self["__excludedLightsAttributeQuery"]["value"] ) + self["__exclusionsSwitch"]["index"].setInput( self["__objectsExistenceQuery"]["exists"] ) + + # This Switch acts as an or of __lightFiltersExistenceQuery.exists and __objectsExistenceQuery.exists + # enabling __collect when either are true. + self["__collectEnabledSwitch"] = Gaffer.Switch() + self["__collectEnabledSwitch"].setup( Gaffer.BoolPlug() ) + self["__collectEnabledSwitch"]["in"].resize( 2 ) + self["__collectEnabledSwitch"]["in"][0].setInput( self["__lightFiltersExistenceQuery"]["exists"] ) + self["__collectEnabledSwitch"]["in"][1].setInput( self["__objectsExistenceQuery"]["exists"] ) + self["__collectEnabledSwitch"]["index"].setInput( self["__objectsExistenceQuery"]["exists"] ) + + self["__collect"] = Gaffer.Collect() + self["__collect"]["contextVariable"].setValue( "__lightLinkingEditorCollect:value" ) + self["__collect"]["indexContextVariable"].setValue( "__lightLinkingEditorCollect:index" ) + self["__collect"].addInput( Gaffer.StringPlug( "inclusions" ) ) + self["__collect"].addInput( Gaffer.StringPlug( "exclusions" ) ) + self["__collect"]["in"]["inclusions"].setInput( self["__inclusionsSwitch"]["out"] ) + self["__collect"]["in"]["exclusions"].setInput( self["__exclusionsSwitch"]["out"] ) + self["__collect"]["enabled"].setInput( self["__collectEnabledSwitch"]["out"] ) + + self["__linkedLightsSetExpressionExpression"] = Gaffer.Expression() + self["__linkedLightsSetExpressionExpression"].setExpression( inspect.cleandoc( + """ + allInclusions = parent["__collect"]["out"]["inclusions"] + allExclusions = parent["__collect"]["out"]["exclusions"] + + linkingExpressions = set() + for inclusions, exclusions in zip( allInclusions, allExclusions ) : + if inclusions and not inclusions.isspace() : + if exclusions and not exclusions.isspace() : + linkingExpressions.add( f"({inclusions}) - ({exclusions})" ) + else : + linkingExpressions.add( f"({inclusions})" ) + + parent["__linkedLightsSetFilter"]["setExpression"] = " ".join( linkingExpressions ) if len( allInclusions ) else "__lights" + """ + ), "python" ) + + IECore.registerRunTimeTyped( Settings, typeName = "GafferSceneUI::LightLinkingEditor::Settings" ) + + def __init__( self, scriptNode, **kw ) : + + column = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, borderWidth = 4, spacing = 4 ) + + GafferSceneUI.SceneEditor.__init__( self, column, scriptNode, **kw ) + + with column : + + with GafferUI.SplitContainer( GafferUI.SplitContainer.Orientation.Horizontal ) : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4 ) : + + with GafferUI.TabbedContainer() as self.__lightsAndSetsTabbedContainer : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4, parenting = { "label" : "Lights" } ) as self.__lightsColumn : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Lights" + ) + + GafferUI.Spacer( size = imath.V2i( 1, 24 ), maximumSize = imath.V2i( 1, 24 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "LightsAndSets" + ) + + self.__lightsPathListing = GafferUI.PathListingWidget( + GafferScene.ScenePath( self.settings()["__lightsHierarchyFilter"]["out"], self.context(), "/" ), + columns = [ _GafferSceneUI._LightEditorLocationNameColumn() ], + selectionMode = GafferUI.PathListingWidget.SelectionMode.Rows, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree, + horizontalScrollMode = GafferUI.ScrollMode.Automatic + ) + self.__lightsPathListing.setSortable( False ) + self.__lightsPathListing.setDragPointer( "objects" ) + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4, parenting = { "label" : "Sets" } ) : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Sets" + ) + + GafferUI.Spacer( size = imath.V2i( 1, 24 ), maximumSize = imath.V2i( 1, 24 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "LightsAndSets" + ) + + self.__setSearchFilter = _GafferSceneUI._SetEditor.SearchFilter() + + self.__setsPathListing = GafferUI.PathListingWidget( + _GafferSceneUI._SetEditor.SetPath( self.settings()["__isolateLightsLinkedToSelection"]["out"], self.context(), "/", filter = Gaffer.CompoundPathFilter( [ self.__setSearchFilter, _GafferSceneUI._SetEditor.EmptySetFilter( scriptNode ) ] ) ), + columns = [ _GafferSceneUI._SetEditor.SetNameColumn() ], + selectionMode = GafferUI.PathListingWidget.SelectionMode.Rows, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree, + ) + self.__setsPathListing.setSortable( False ) + self.__setsPathListing.dragBeginSignal().connectFront( Gaffer.WeakMethod( self.__setsDragBegin ) ) + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + self.__statusLabel = GafferUI.Label( "" ) + # Ensure a long status text doesn't enforce the minimum width of this side of the SplitContainer. + self.__statusLabel._qtWidget().setSizePolicy( QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Fixed ) + GafferUI.Spacer( size = imath.V2i( 1, 22 ), maximumSize = imath.V2i( 1, 22 ) ) + + with GafferUI.TabbedContainer() as self.__objectsAndLightFiltersTabbedContainer : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4, parenting = { "label" : "Objects" } ) as self.__objectsColumn : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Filter", + ) + + GafferUI.Spacer( size = imath.V2i( 1, 24 ), maximumSize = imath.V2i( 1, 24 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "ObjectsAndLightFilters" + ) + + self.__linkedLightsColumn = self.__attributeColumn( "linkedLights", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + self.__linkedLightsExclusionsColumn = self.__attributeColumn( "linkedLights:exclusions", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + self.__shadowedLightsColumn = self.__attributeColumn( "shadowedLights", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + self.__shadowedLightsExclusionsColumn = self.__attributeColumn( "shadowedLights:exclusions", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + + self.__objectsPathListing = GafferUI.PathListingWidget( + GafferScene.ScenePath( self.settings()["__filteredObjects"]["out"], self.context(), "/" ), + columns = [ + GafferUI.PathListingWidget.StandardColumn( "Name", "name", GafferUI.PathColumn.SizeMode.Stretch ), + self.__linkedLightsColumn, + self.__linkedLightsExclusionsColumn, + self.__shadowedLightsColumn, + self.__shadowedLightsExclusionsColumn, + ], + selectionMode = GafferUI.PathListingWidget.SelectionMode.Cells, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree, + horizontalScrollMode = GafferUI.ScrollMode.Automatic + ) + self.__objectsPathListing.setSortable( False ) + GafferSceneUI.Private.InspectorColumn.connectToDragBeginSignal( self.__objectsPathListing ) + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + self.__linkSelectedButton = GafferUI.Button( image = "link.png", toolTip = "Link", hasFrame = False ) + self.__linkSelectedButton.clickedSignal().connect( functools.partial( Gaffer.WeakMethod( self.__linkSelected ), True ) ) + + self.__unlinkSelectedButton = GafferUI.Button( image = "unlink.png", toolTip = "Unlink", hasFrame = False ) + self.__unlinkSelectedButton.clickedSignal().connect( functools.partial( Gaffer.WeakMethod( self.__linkSelected ), False ) ) + + GafferUI.Spacer( size = imath.V2i( 1, 22 ), maximumSize = imath.V2i( 1, 22 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Mode", + ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Attribute", + ) + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4, parenting = { "label" : "Light Filters" } ) : + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "LightFilters", + ) + + GafferUI.Spacer( size = imath.V2i( 1, 24 ), maximumSize = imath.V2i( 1, 24 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "ObjectsAndLightFilters" + ) + + self.__filteredLightsColumn = self.__attributeColumn( "filteredLights", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + self.__filteredLightsExclusionsColumn = self.__attributeColumn( "filteredLights:exclusions", self.settings()["__adaptedIn"], self.settings()["editScope"] ) + + self.__lightFiltersPathListing = GafferUI.PathListingWidget( + GafferScene.ScenePath( self.settings()["__lightFilterHierarchyFilter"]["out"], self.context(), "/" ), + columns = [ + _GafferSceneUI._LightEditorLocationNameColumn( GafferUI.PathColumn.SizeMode.Stretch ), + self.__filteredLightsColumn, + self.__filteredLightsExclusionsColumn, + ], + selectionMode = GafferUI.PathListingWidget.SelectionMode.Cells, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree, + horizontalScrollMode = GafferUI.ScrollMode.Automatic + ) + self.__lightFiltersPathListing.setSortable( False ) + GafferSceneUI.Private.InspectorColumn.connectToDragBeginSignal( self.__lightFiltersPathListing ) + + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + self.__filterSelectedButton = GafferUI.Button( image = "link.png", toolTip = "Link", hasFrame = False ) + self.__filterSelectedButton.clickedSignal().connect( functools.partial( Gaffer.WeakMethod( self.__filterSelected ), True ) ) + + self.__unfilterSelectedButton = GafferUI.Button( image = "unlink.png", toolTip = "Unlink", hasFrame = False ) + self.__unfilterSelectedButton.clickedSignal().connect( functools.partial( Gaffer.WeakMethod( self.__filterSelected ), False ) ) + + GafferUI.Spacer( size = imath.V2i( 1, 22 ), maximumSize = imath.V2i( 1, 22 ) ) + + GafferUI.PlugLayout( + self.settings(), + orientation = GafferUI.ListContainer.Orientation.Horizontal, + rootSection = "Mode", + ) + + self.__lightsSelectionChangedConnection = self.__lightsPathListing.selectionChangedSignal().connect( + Gaffer.WeakMethod( self.__selectionChanged ) + ) + self.__setsPathListing.selectionChangedSignal().connect( + Gaffer.WeakMethod( self.__setsSelectionChanged ) + ) + self.__objectsSelectionChangedConnection = self.__objectsPathListing.selectionChangedSignal().connect( + Gaffer.WeakMethod( self.__selectionChanged ) + ) + self.__lightFiltersSelectionChangedConnection = self.__lightFiltersPathListing.selectionChangedSignal().connect( + Gaffer.WeakMethod( self.__selectionChanged ) + ) + + self.__lightsAndSetsTabbedContainer.currentChangedSignal().connect( Gaffer.WeakMethod( self.__currentTabChanged ) ) + self.__objectsAndLightFiltersTabbedContainer.currentChangedSignal().connect( Gaffer.WeakMethod( self.__currentTabChanged ) ) + + self.__lightsPathListing.columnContextMenuSignal().connect( Gaffer.WeakMethod( self.__columnContextMenuSignal ) ) + self.__setsPathListing.columnContextMenuSignal().connect( Gaffer.WeakMethod( self.__columnContextMenuSignal ) ) + self.__objectsPathListing.columnContextMenuSignal().connect( Gaffer.WeakMethod( self.__columnContextMenuSignal ) ) + + self.__lightsPathListing.keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPressSignal ) ) + self.__setsPathListing.keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPressSignal ) ) + self.__objectsPathListing.keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPressSignal ) ) + self.__lightFiltersPathListing.keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPressSignal ) ) + + self.__selectedPathsChangedConnection = GafferSceneUI.ScriptNodeAlgo.selectedPathsChangedSignal( scriptNode ).connect( + Gaffer.WeakMethod( self.__selectedPathsChanged ) + ) + + self.settings()["__filteredObjects"].plugDirtiedSignal().connect( Gaffer.WeakMethod( self.__objectsPlugDirtied ) ) + + Gaffer.Metadata.nodeValueChangedSignal().connect( Gaffer.WeakMethod( self.__metadataChanged ) ) + + self._updateFromSet() + self.__transferSelectionFromScriptNode() + self.__updateButtonStatus() + + def scene( self ) : + + return self.settings()["in"].getInput() + + ## Returns the widget used for showing the main scene listing, with the + # intention that clients can add custom context menu items via + # `sceneListing.columnContextMenuSignal()`. + # + # > Caution : This currently returns a PathListingWidget, but in future + # > will probably return a more specialised widget with fewer privileges. + # > Please limit usage to `columnContextMenuSignal()`. + def sceneListing( self ) : + + return self.__lightsPathListing + + def __repr__( self ) : + + return "GafferSceneUI.LightLinkingEditor( scriptNode )" + + def _updateFromContext( self, modifiedItems ) : + + self.__lazyUpdateFromContext() + + def _updateFromSettings( self, plug ) : + + if plug == self.settings()["setsFilter"] : + self.__setSearchFilter.setMatchPattern( plug.getValue() ) + elif plug == self.settings()["onlyLinkedToSelection"] : + self.__lazyUpdateLinkedLightsSetFilter() + elif plug in ( self.settings()["in"], self.settings()["editScope"] ) : + self.__updateButtonStatus() + + @classmethod + def __attributeColumn( cls, attributeName, scene, editScope, columnName = None ) : + + label = Gaffer.Metadata.value( "attribute:" + attributeName, "columnLayout:label" ) or Gaffer.Metadata.value( "attribute:" + attributeName, "label" ) + if not columnName : + columnName = label or attributeName + + toolTip = "

{}

Attribute : {}".format( label or columnName, attributeName ) + description = Gaffer.Metadata.value( "attribute:" + attributeName, "description" ) + if description : + ## \todo PathListingWidget's PathModel should be handling this instead. + toolTip += GafferUI.DocumentationAlgo.markdownToHTML( description ) + + return GafferSceneUI.Private.InspectorColumn( + GafferSceneUI.Private.AttributeInspector( scene, editScope, attributeName ), + columnName, + toolTip + ) + + @GafferUI.LazyMethod( deferUntilVisible = False, deferUntilPlaybackStops = True ) + def __lazyUpdateFromContext( self ) : + + self.__lightsPathListing.getPath().setContext( self.context() ) + self.__setsPathListing.getPath().setContext( self.context() ) + self.__objectsPathListing.getPath().setContext( self.context() ) + self.__lightFiltersPathListing.getPath().setContext( self.context() ) + + def __metadataChanged( self, nodeTypeId, key, node ) : + + editScope = self.editScope() + if editScope is None : + return + + if Gaffer.MetadataAlgo.readOnlyAffectedByChange( editScope, nodeTypeId, key, node ) : + self.__updateButtonStatus() + + def __currentTabChanged( self, *unused ) : + + self.__updateButtonStatus() + + def __selectedPathsChanged( self, scriptNode ) : + + self.__transferSelectionFromScriptNode() + + def __setsSelectionChanged( self, pathListing ) : + + self.__updateButtonStatus() + + def __selectionChanged( self, pathListing ) : + + assert( pathListing in ( self.__lightsPathListing, self.__objectsPathListing, self.__lightFiltersPathListing ) ) + + ## \todo Ideally we'd allow Ctrl-click to still accumulate rather than clear the other's selection. + if pathListing is self.__objectsPathListing : + with Gaffer.Signals.BlockedConnection( self.__lightFiltersSelectionChangedConnection ) : + self.__lightFiltersPathListing.setSelection( [IECore.PathMatcher()] * ( len( self.__lightFiltersPathListing.getColumns() ) ) ) + elif pathListing is self.__lightFiltersPathListing : + with Gaffer.Signals.BlockedConnection( self.__objectsSelectionChangedConnection ) : + self.__objectsPathListing.setSelection( [IECore.PathMatcher()] * ( len( self.__objectsPathListing.getColumns() ) ) ) + + combinedSelection = self.__lightsPathListing.getSelection() + combinedSelection.addPaths( self.__objectsPathListing.getSelection()[0] ) + combinedSelection.addPaths( self.__lightFiltersPathListing.getSelection()[0] ) + with Gaffer.Signals.BlockedConnection( self.__selectedPathsChangedConnection ) : + GafferSceneUI.ScriptNodeAlgo.setSelectedPaths( self.scriptNode(), combinedSelection ) + + if pathListing is not self.__lightsPathListing : + self.__lazyUpdateLinkedLightsSetFilter() + + self.__updateButtonStatus() + + def __partitionedSelection( self ) : + + selection = GafferSceneUI.ScriptNodeAlgo.getSelectedPaths( self.scriptNode() ) + if selection.isEmpty() or self.scene() is None : + return IECore.PathMatcher(), IECore.PathMatcher(), IECore.PathMatcher() + + with self.context() : + lights = self.settings()["__adaptedIn"].set( "__lights" ).value + lightFilters = self.settings()["__adaptedIn"].set( "__lightFilters" ).value + + lightSelection = lights.intersection( selection ) + selection.removePaths( lightSelection ) + lightFilterSelection = lightFilters.intersection( selection ) + selection.removePaths( lightFilterSelection ) + + # Include ancestors of lights and lightFilters in their selections + for path in selection.paths() : + if lightFilters.match( path ) & IECore.PathMatcher.Result.DescendantMatch : + lightFilterSelection.addPath( path ) + if lights.match( path ) & IECore.PathMatcher.Result.DescendantMatch : + lightSelection.addPath( path ) + + return lightSelection, selection, lightFilterSelection + + @GafferUI.LazyMethod( deferUntilPlaybackStops = True ) + def __transferSelectionFromScriptNode( self ) : + + lights, objects, lightFilters = self.__partitionedSelection() + + with Gaffer.Signals.BlockedConnection( self.__lightsSelectionChangedConnection ) : + self.__lightsPathListing.setSelection( lights, scrollToFirst = True ) + with Gaffer.Signals.BlockedConnection( self.__objectsSelectionChangedConnection ) : + self.__objectsPathListing.setSelection( + [objects] + [IECore.PathMatcher()] * ( len( self.__objectsPathListing.getColumns() ) - 1 ), scrollToFirst = True + ) + with Gaffer.Signals.BlockedConnection( self.__lightFiltersSelectionChangedConnection ) : + self.__lightFiltersPathListing.setSelection( + [lightFilters] + [IECore.PathMatcher()] * ( len( self.__lightFiltersPathListing.getColumns() ) - 1 ), scrollToFirst = True + ) + + self.__updateLinkedLightsSetFilter() + self.__updateButtonStatus() + + @GafferUI.LazyMethod() + def __lazyUpdateLinkedLightsSetFilter( self ) : + + self.__updateLinkedLightsSetFilter() + + def __updateLinkedLightsSetFilter( self ) : + + if not self.settings()["onlyLinkedToSelection"].getValue() or self.scene() is None : + return + + selection = IECore.PathMatcher() + for pathListing in ( self.__objectsPathListing, self.__lightFiltersPathListing ) : + # Accumulate selection across all columns so selecting + # only cells in non-name columns doesn't cause the filter + # to clear. + for s in pathListing.getSelection() : + selection.addPaths( s ) + + self.settings()["__collect"]["contextValues"].setValue( IECore.StringVectorData( selection.paths() ) ) + + def __objectsPlugDirtied( self, plug ) : + + if plug == self.settings()["__filteredObjects"]["out"]["attributes"] : + self.__lazyUpdateLinkedLightsSetFilter() + + def __columnContextMenuSignal( self, column, pathListing, menuDefinition ) : + + selection = pathListing.getSelection() + if pathListing == self.__lightsPathListing : + + if menuDefinition.size() : + menuDefinition.append( "/__lightLinkingEditorCopyDivider", { "divider" : True } ) + + menuDefinition.append( + "Copy Path{}".format( "" if selection.size() == 1 else "s" ), + { + "command" : functools.partial( Gaffer.WeakMethod( self.__copyPaths ), selection ), + "active" : not selection.isEmpty(), + "shortCut" : "Ctrl+C" + } + ) + + menuDefinition.append( "/__lightLinkingEditorSelectDivider", { "divider" : True } ) + + menuDefinition.append( + "Select Linked Objects", + { + "command" : Gaffer.WeakMethod( self.__selectLinkedObjects ) + } + ) + + elif pathListing == self.__objectsPathListing : + + columns = pathListing.getColumns() + if columns.index( column ) != 0 : + return + + if menuDefinition.size() : + menuDefinition.append( "/__lightLinkingEditorSelectDivider", { "divider" : True } ) + + menuDefinition.append( + "Select Linked Lights", + { + "command" : Gaffer.WeakMethod( self.__selectLinkedLights ) + } + ) + + elif pathListing == self.__setsPathListing : + + selectedSetNames = self.__selectedSetNames() + + menuDefinition.append( + "/Copy Set Name{}".format( "" if len( selectedSetNames ) == 1 else "s" ), + { + "command" : Gaffer.WeakMethod( self.__copySelectedSetNames ), + "active" : len( selectedSetNames ) > 0, + "shortCut" : "Ctrl+C" + } + ) + + menuDefinition.append( + "/Copy Set Members", + { + "command" : Gaffer.WeakMethod( self.__copySetMembers ), + "active" : len( selectedSetNames ) > 0, + "shortCut" : "Ctrl+Shift+C" + } + ) + + menuDefinition.append( + "/Select Set Members", + { + "command" : Gaffer.WeakMethod( self.__selectSetMembers ), + "active" : len( selectedSetNames ) > 0, + } + ) + + def __keyPressSignal( self, pathListing, event ) : + + if pathListing == self.__lightsPathListing : + + if event.key == "C" and event.modifiers == event.Modifiers.Control : + self.__copyPaths( pathListing.getSelection() ) + return True + + elif pathListing == self.__setsPathListing : + + if event.key == "C" and event.modifiers == event.Modifiers.Control : + self.__copySelectedSetNames() + return True + elif event.key == "C" and event.modifiers == event.Modifiers.ShiftControl : + self.__copySetMembers() + return True + + if event.key == "F" : + self.__frameSelectedPaths( pathListing ) + return True + + return False + + def __copyPaths( self, selection ) : + + selection = selection[0] if isinstance( selection, list ) else selection + if not selection.isEmpty() : + data = IECore.StringVectorData( selection.paths() ) + self.scriptNode().ancestor( Gaffer.ApplicationRoot ).setClipboardContents( data ) + + def __frameSelectedPaths( self, pathListing ) : + + selection = pathListing.getSelection() + selection = selection[0] if isinstance( selection, list ) else selection + if not selection.isEmpty() : + pathListing.expandTo( selection ) + pathListing.scrollToFirst( selection ) + + def __selectLinkedObjects( self, *unused ) : + + selectedLights = self.__lightsPathListing.getSelection() + + dialogue = GafferUI.BackgroundTaskDialogue( "Selecting Linked Objects" ) + with self.context() : + result = dialogue.waitForBackgroundTask( + functools.partial( + GafferScene.SceneAlgo.linkedObjects, + self.settings()["__adaptedIn"], + selectedLights + ) + ) + + if not isinstance( result, Exception ) : + GafferSceneUI.ScriptNodeAlgo.setSelectedPaths( self.scriptNode(), result ) + + def __selectLinkedLights( self, *unused ) : + + selectedObjects = self.__objectsPathListing.getSelection()[0] + + dialogue = GafferUI.BackgroundTaskDialogue( "Selecting Linked Lights" ) + with self.context() : + result = dialogue.waitForBackgroundTask( + functools.partial( + GafferScene.SceneAlgo.linkedLights, + self.settings()["__adaptedIn"], + selectedObjects + ) + ) + + if not isinstance( result, Exception ) : + GafferSceneUI.ScriptNodeAlgo.setSelectedPaths( self.scriptNode(), result ) + + def __selectedSetNames( self ) : + + selection = self.__setsPathListing.getSelection() + path = self.__setsPathListing.getPath().copy() + result = [] + for p in selection.paths() : + path.setFromString( p ) + setName = path.property( "setPath:setName" ) + if setName is not None : + result.append( setName ) + + return result + + def __setsDragBegin( self, widget, event ) : + + path = self.__setsPathListing.pathAt( imath.V2f( event.line.p0.x, event.line.p0.y ) ) + selection = self.__setsPathListing.getSelection() + setNames = [] + if selection.match( str( path ) ) & IECore.PathMatcher.Result.ExactMatch : + setNames = self.__selectedSetNames() + else : + setName = path.property( "setPath:setName" ) + if setName is not None : + setNames.append( setName ) + + GafferUI.Pointer.setCurrent( "sets" ) + return IECore.StringVectorData( setNames ) + + def __copySelectedSetNames( self, *unused ) : + + self.scriptNode().ancestor( Gaffer.ApplicationRoot ).setClipboardContents( + IECore.StringVectorData( self.__selectedSetNames() ) + ) + + def __getSetMembers( self, setNames, *unused ) : + + result = IECore.PathMatcher() + with Gaffer.Context( self.context() ) : + for setName in setNames : + result.addPaths( self.settings()["__adaptedIn"].set( setName ).value ) + + return result + + def __selectSetMembers( self, *unused ) : + + GafferSceneUI.ScriptNodeAlgo.setSelectedPaths( self.scriptNode(), self.__getSetMembers( self.__selectedSetNames() ) ) + + def __copySetMembers( self, *unused ) : + + data = self.__getSetMembers( self.__selectedSetNames() ).paths() + self.scriptNode().ancestor( Gaffer.ApplicationRoot ).setClipboardContents( IECore.StringVectorData( data ) ) + + def __selectedLights( self ) : + + if self.__lightsAndSetsTabbedContainer.getCurrent() != self.__lightsColumn : + return self.__selectedSetNames() + + lights = [] + with self.context() : + lightSet = self.settings()["__adaptedIn"].set( "__lights" ).value + for path in self.__lightsPathListing.getSelection().paths() : + if lightSet.match( path ) & IECore.PathMatcher.Result.ExactMatch : + lights.append( path ) + + return lights + + def __editScopeNonEditableReason( self ) : + + input = self.settings()["in"].getInput() + if input is None : + return "No scene viewed" + + editScope = self.editScope() + if editScope is None : + return "" + + inputNode = input.node() + if inputNode != editScope and editScope not in Gaffer.NodeAlgo.upstreamNodes( inputNode ) : + return "The target edit scope {} is downstream of the viewed node.".format( editScope.getName() ) + if Gaffer.MetadataAlgo.readOnly( editScope ) : + return "The target edit scope {} is read-only.".format( editScope.getName() ) + with self.context() : + if not editScope["enabled"].getValue() : + return "The target edit scope {} is disabled.".format( editScope.getName() ) + + return "" + + def __updateButtonStatus( self, *unused ) : + + nonEditableReason = self.__editScopeNonEditableReason() + + target = "lights" if self.__lightsAndSetsTabbedContainer.getCurrent() == self.__lightsColumn else "sets" + + if self.__objectsAndLightFiltersTabbedContainer.getCurrent() == self.__objectsColumn : + + objectsSelected = not self.__objectsPathListing.getSelection()[0].isEmpty() + selection = objectsSelected and len( self.__selectedLights() ) > 0 + + if not selection : + nonEditableReason = f"To edit light linking, first select a combination of {target} and objects." + + self.__linkSelectedButton.setEnabled( not nonEditableReason ) + self.__linkSelectedButton.setToolTip( nonEditableReason if nonEditableReason else f"Click to link selected {target} to the selected objects" ) + + self.__unlinkSelectedButton.setEnabled( not nonEditableReason ) + self.__unlinkSelectedButton.setToolTip( nonEditableReason if nonEditableReason else f"Click to unlink selected {target} from the selected objects" ) + + else : + + objectsSelected = not self.__lightFiltersPathListing.getSelection()[0].isEmpty() + selection = objectsSelected and len( self.__selectedLights() ) > 0 + + if not selection : + nonEditableReason = f"To edit light filter assignment, first select a combination of {target} and light filters." + + self.__filterSelectedButton.setEnabled( not nonEditableReason ) + self.__filterSelectedButton.setToolTip( nonEditableReason if nonEditableReason else f"Click to assign the selected light filters to the selected {target}" ) + + self.__unfilterSelectedButton.setEnabled( not nonEditableReason ) + self.__unfilterSelectedButton.setToolTip( nonEditableReason if nonEditableReason else f"Click to unassign the selected light filters from the selected {target}" ) + + self.__statusLabel.setText( nonEditableReason or f"Use the buttons to link or unlink the selected {target} and locations" ) + + def __linkSelected( self, link, *unused ) : + + self.__editSelectedLightLinks( link, self.settings()["attribute"].getValue(), self.settings()["mode"].getValue() == "replace" ) + + def __filterSelected( self, link, *unused ) : + + self.__editSelectedLightLinks( link, "filteredLights", self.settings()["mode"].getValue() == "replace" ) + + ## \todo Add equivalent linking actions to the SceneView "Light Links" context menu. + def __editSelectedLightLinks( self, link, attribute, replaceExisting = False ) : + + if attribute == "linkedLights" : + pathListing = self.__objectsPathListing + inclusionsColumn = self.__linkedLightsColumn + exclusionsColumn = self.__linkedLightsExclusionsColumn + elif attribute == "shadowedLights" : + pathListing = self.__objectsPathListing + inclusionsColumn = self.__shadowedLightsColumn + exclusionsColumn = self.__shadowedLightsExclusionsColumn + elif attribute == "filteredLights" : + pathListing = self.__lightFiltersPathListing + inclusionsColumn = self.__filteredLightsColumn + exclusionsColumn = self.__filteredLightsExclusionsColumn + + pathsToEdit = pathListing.getSelection()[0].paths() + if not pathsToEdit : + return + + targets = self.__selectedLights() + rootPath = pathListing.getPath() + path = rootPath.copy() + edits = [] + warnings = set() + with self.context() : + + for pathString in pathsToEdit : + path.setFromString( pathString ) + if not path.isValid() : + continue + + inclusionsInspection = inclusionsColumn.inspect( path ) + if replaceExisting and link : + inclusions = "" + else : + inclusions = inclusionsInspection.value() + inclusions = inclusions.value if inclusions is not None else "" + + exclusionsInspection = exclusionsColumn.inspect( path ) + if replaceExisting and not link : + exclusions = "" + else : + exclusions = exclusionsInspection.value() + exclusions = exclusions.value if exclusions is not None else "" + + if link : + newInclusions = Gaffer.SetExpressionAlgo.include( inclusions, " ".join( targets ) ) + newExclusions = Gaffer.SetExpressionAlgo.exclude( exclusions, " ".join( targets ) ) + else : + newInclusions = Gaffer.SetExpressionAlgo.remove( inclusions, " ".join( targets ) ) + newExclusions = Gaffer.SetExpressionAlgo.include( exclusions, " ".join( targets ) ) + + if newInclusions != inclusions : + value = IECore.StringData( newInclusions ) + if inclusionsInspection.canEdit( value ) : + edits.append( ( inclusionsInspection, value ) ) + else : + warnings.add( f"{inclusionsColumn.headerData( rootPath ).value} : {inclusionsInspection.nonEditableReason( value )}" ) + + if newExclusions != exclusions : + value = IECore.StringData( newExclusions ) + if exclusionsInspection.canEdit( value ) : + edits.append( ( exclusionsInspection, value ) ) + else : + warnings.add( f"{exclusionsColumn.headerData( rootPath ).value} : {exclusionsInspection.nonEditableReason( value )}" ) + + if warnings : + GafferUI.PopupWindow.showWarning( "
".join( sorted( warnings ) ), parent = self ) + return + + with Gaffer.UndoScope( self.scriptNode() ) : + for inspection, value in edits : + inspection.edit( value ) + +GafferUI.Editor.registerType( "LightLinkingEditor", LightLinkingEditor ) + +########################################################################## +# Metadata controlling the settings UI +########################################################################## + +Gaffer.Metadata.registerNode( + + LightLinkingEditor.Settings, + + plugs = { + + "*" : { + + "label" : "", + + }, + + "lightsFilter" : { + + "description" : + """ + Filters the input scene to isolate locations with matching names. + The filter may contain any of Gaffer's standard wildcards, and may + either be used to match individual location names or entire paths. + + Examples + -------- + + - `building` : Matches any location in the scene which has the + text `building` anywhere in its name. + - `/cityA/.../building*` : Matches only locations within `cityA` + whose name starts with `building`. + """, + + "plugValueWidget:type" : "GafferUI.TogglePlugValueWidget", + "togglePlugValueWidget:image:on" : "searchOn.png", + "togglePlugValueWidget:image:off" : "search.png", + # We need a non-default value to toggle to, so that the first + # toggling can highlight the icon. `*` seems like a reasonable value + # since it has no effect on the filtering, and hints that wildcards + # are available. + "togglePlugValueWidget:defaultToggleValue" : "*", + "stringPlugValueWidget:placeholderText" : "Filter Lights...", + "layout:section" : "Lights" + + }, + + "lightsSetFilter" : { + + "description" : + """ + Filters the input scene to isolate locations belonging to specific + sets. + """, + + "label" : "", + "plugValueWidget:type" : "GafferSceneUI.SceneEditor._SetFilterPlugValueWidget", + "setFilterPlugValueWidget:excludedSetNames" : IECore.StringVectorData( [ "__lights", "__lightFilters", "__cameras", "__coordinateSystems" ] ), + "layout:section" : "Lights" + + }, + + "setsFilter" : { + + "description" : + """ + Filters the displayed sets by name. Accepts standard wildcards such as `*` and `?`. + """, + + "plugValueWidget:type" : "GafferUI.TogglePlugValueWidget", + "togglePlugValueWidget:image:on" : "searchOn.png", + "togglePlugValueWidget:image:off" : "search.png", + "togglePlugValueWidget:defaultToggleValue" : "*", + "togglePlugValueWidget:customWidgetType" : "GafferSceneUI.SetEditor._FilterPlugValueWidget", + "stringPlugValueWidget:placeholderText" : "Filter Sets...", + "layout:section" : "Sets" + + }, + + "onlyLinkedToSelection" : { + + "description" : "Only show lights and sets containing lights linked to the selected objects and light filters.", + "boolPlugValueWidget:labelVisible" : True, + "layout:section" : "LightsAndSets", + + }, + + "filter" : { + + "stringPlugValueWidget:placeholderText" : "Filter Objects...", + + }, + + "setFilter" : { + + "setFilterPlugValueWidget:excludedSetNames" : IECore.StringVectorData( [ "__lights", "__lightFilters", "defaultLights" ] ), + + }, + + "lightFiltersFilter" : { + + "description" : + """ + Filters the input scene to isolate locations with matching names. + The filter may contain any of Gaffer's standard wildcards, and may + either be used to match individual location names or entire paths. + + Examples + -------- + + - `building` : Matches any location in the scene which has the + text `building` anywhere in its name. + - `/cityA/.../building*` : Matches only locations within `cityA` + whose name starts with `building`. + """, + + "plugValueWidget:type" : "GafferUI.TogglePlugValueWidget", + "togglePlugValueWidget:image:on" : "searchOn.png", + "togglePlugValueWidget:image:off" : "search.png", + # We need a non-default value to toggle to, so that the first + # toggling can highlight the icon. `*` seems like a reasonable value + # since it has no effect on the filtering, and hints that wildcards + # are available. + "togglePlugValueWidget:defaultToggleValue" : "*", + "stringPlugValueWidget:placeholderText" : "Filter Light Filters...", + "layout:section" : "LightFilters" + + }, + + "lightFiltersSetFilter" : { + + "description" : + """ + Filters the input scene to isolate locations belonging to specific + sets. + """, + + "label" : "", + "plugValueWidget:type" : "GafferSceneUI.SceneEditor._SetFilterPlugValueWidget", + "setFilterPlugValueWidget:excludedSetNames" : IECore.StringVectorData( [ "__lights", "__lightFilters", "__cameras", "__coordinateSystems", "defaultLights" ] ), + "layout:section" : "LightFilters" + + }, + + "editScope" : { + + "plugValueWidget:type" : "GafferUI.EditScopeUI.EditScopePlugValueWidget", + "layout:width" : 130, + "layout:section" : "ObjectsAndLightFilters", + + }, + + "mode" : { + + "description" : + """ + How the edit is applied. + + - Append : Modifies the input set expression to include or exclude the selected lights or sets. + - Replace : Replaces the input set expression with only the selected lights or sets. + """, + + "label" : "Mode", + "plugValueWidget:type" : "GafferUI.PresetsPlugValueWidget", + "labelPlugValueWidget:showValueChangedIndicator" : False, + "preset:Append" : "append", + "preset:Replace" : "replace", + "layout:width" : 130, + "layout:section" : "Mode" + + }, + + "attribute" : { + + "description" : + """ + The attribute to edit. + """, + + "label" : "Attribute", + "plugValueWidget:type" : "GafferUI.PresetsPlugValueWidget", + "labelPlugValueWidget:showValueChangedIndicator" : False, + "preset:Linked Lights" : "linkedLights", + "preset:Shadowed Lights" : "shadowedLights", + "layout:width" : 130, + "layout:section" : "Attribute" + + }, + + } + +) diff --git a/python/GafferSceneUI/RenderPassEditor.py b/python/GafferSceneUI/RenderPassEditor.py index 9b63a08c8b6..5562d829a3c 100644 --- a/python/GafferSceneUI/RenderPassEditor.py +++ b/python/GafferSceneUI/RenderPassEditor.py @@ -187,7 +187,7 @@ def __optionColumnCreator( cls, optionName, section, columnName = None ) : if section == "Favourites" : optionLabel = Gaffer.Metadata.value( "option:" + optionName, "label" ) else : - optionLabel = Gaffer.Metadata.value( "option:" + optionName, f"columnLayout:label" ) or Gaffer.Metadata.value( "option:" + optionName, "label" ) + optionLabel = Gaffer.Metadata.value( "option:" + optionName, "columnLayout:label" ) or Gaffer.Metadata.value( "option:" + optionName, "label" ) if not columnName : columnName = optionLabel or optionName.split( ":" )[-1] diff --git a/python/GafferSceneUI/SceneEditor.py b/python/GafferSceneUI/SceneEditor.py index 02bd7ad1d9f..339c7240844 100644 --- a/python/GafferSceneUI/SceneEditor.py +++ b/python/GafferSceneUI/SceneEditor.py @@ -324,6 +324,9 @@ def _setFilterExpression( cls, filterValue, setFilterValue ) : # _SetFilterPlugValueWidget # ========================= +# Supported plug metadata : +# +# - "setFilterPlugValueWidget:excludedSetNames" : A list of set names that shouldn't be shown in the menu. class _SetFilterPlugValueWidget( GafferUI.PlugValueWidget ) : def __init__( self, plug, **kw ) : @@ -341,6 +344,7 @@ def __init__( self, plug, **kw ) : self.__lastNonDefaultValue = None self.__availableSetNames = [] + self.__excludedSetNames = set() def _auxiliaryPlugs( self, plug ) : @@ -376,13 +380,16 @@ def _updateFromValues( self, values, exception ) : self.__button.setImage( "setFilter{}.png".format( "On" if value else "Off" ) ) self.__availableSetNames = values[0]["setNames"] + def _updateFromMetadata( self ) : + + self.__excludedSetNames = set( Gaffer.Metadata.value( self.getPlug(), "setFilterPlugValueWidget:excludedSetNames" ) or [] ) + def __setsMenuDefinition( self ) : m = IECore.MenuDefinition() - availableSets = set( self.__availableSetNames ) - - builtInSets = { "__lights", "__lightFilters", "__cameras", "__coordinateSystems" } + availableSets = set( self.__availableSetNames ) - self.__excludedSetNames + builtInSets = { "__lights", "__lightFilters", "__cameras", "__coordinateSystems" } - self.__excludedSetNames selectedSets = set( self.getPlug().getValue().split() ) m.append( @@ -398,6 +405,11 @@ def __setsMenuDefinition( self ) : m.append( "/EnabledDivider", { "divider" : True } ) + if not ( availableSets | selectedSets | builtInSets ) : + + m.append( "/No sets available", { "active" : False } ) + return m + def item( setName ) : updatedSets = set( selectedSets ) @@ -422,7 +434,7 @@ def item( setName ) : for s in sorted( availableSets | selectedSets ) : if s in builtInSets : continue - if not haveDivider : + if builtInSets and not haveDivider : m.append( "/BuiltInDivider", { "divider" : True } ) haveDivider = True m.append( "/" + pathFn( s ), item( s ) ) diff --git a/python/GafferSceneUI/SceneHistoryUI.py b/python/GafferSceneUI/SceneHistoryUI.py index 8ecbc99612c..3d4b9d54d1f 100644 --- a/python/GafferSceneUI/SceneHistoryUI.py +++ b/python/GafferSceneUI/SceneHistoryUI.py @@ -65,7 +65,7 @@ def connectToEditor( editor ) : if isinstance( editor, GafferUI.Viewer ) : editor.keyPressSignal().connect( __viewerKeyPress ) - elif isinstance( editor, GafferSceneUI.HierarchyView ) or isinstance( editor, GafferSceneUI.LightEditor ) : + elif isinstance( editor, ( GafferSceneUI.HierarchyView, GafferSceneUI.LightEditor, GafferSceneUI.LightLinkingEditor ) ) : editor.keyPressSignal().connect( __hierarchyViewKeyPress ) elif isinstance( editor, GafferUI.NodeEditor ) : editor.keyPressSignal().connect( __nodeEditorKeyPress ) diff --git a/python/GafferSceneUI/_InspectorColumn.py b/python/GafferSceneUI/_InspectorColumn.py index 83d22cb553e..4051e73acde 100644 --- a/python/GafferSceneUI/_InspectorColumn.py +++ b/python/GafferSceneUI/_InspectorColumn.py @@ -974,10 +974,9 @@ def __orderedSelection( pathListing ) : for path in selection.paths() : rows.setdefault( path, [] ).append( column ) - matrix = [] - orderedPaths = pathListing.visualOrder( IECore.PathMatcher( list( rows.keys() ) ) ) - for path, columns in sorted( rows.items(), key = lambda item : orderedPaths.index( item[0] ) ) : - matrix.append( ( path, columns ) ) + matrix = [ + ( path, rows[path] ) for path in pathListing.visualOrder( IECore.PathMatcher( list( rows.keys() ) ) ) + ] return matrix diff --git a/python/GafferSceneUI/__init__.py b/python/GafferSceneUI/__init__.py index 3746286277c..a3dca571ded 100644 --- a/python/GafferSceneUI/__init__.py +++ b/python/GafferSceneUI/__init__.py @@ -53,6 +53,7 @@ from .SetEditor import SetEditor from .RenderPassEditor import RenderPassEditor from .AttributeEditor import AttributeEditor +from .LightLinkingEditor import LightLinkingEditor from . import SceneHistoryUI from . import EditScopeUI from . import _InspectorColumn diff --git a/python/GafferSceneUITest/InspectorColumnTest.py b/python/GafferSceneUITest/InspectorColumnTest.py index 79530ba3765..ee5d01f6c94 100644 --- a/python/GafferSceneUITest/InspectorColumnTest.py +++ b/python/GafferSceneUITest/InspectorColumnTest.py @@ -790,3 +790,45 @@ def testCancellation( self ) : with self.assertRaises( IECore.Cancelled ) : column.cellData( path, canceller ) + + def testInvalidPathsInSelection( self ) : + + a = Gaffer.ApplicationRoot() + s = Gaffer.ScriptNode() + a["scripts"]["testScript"] = s + self.__testScene( s ) + + w = GafferUI.PathListingWidget( + GafferScene.ScenePath( s["parent"]["out"], Gaffer.Context(), "/" ), + columns = [ + GafferSceneUI.Private.InspectorColumn( GafferSceneUI.Private.AttributeInspector( s["parent"]["out"], None, "test:string" ) ), + ], + selectionMode = GafferUI.PathListingWidget.SelectionMode.Cells, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree + ) + + e = InspectorColumnTest.TestEditor( s ) + e.addPathListing( w ) + + # Ensure a selection that contains paths not represented by this + # PathListingWidget does not error, and instead only returns data + # for the valid paths. + + w.setSelection( [ IECore.PathMatcher( [ "/sphere", "/notAPath" ] ) ] ) + _GafferUI._pathModelWaitForPendingUpdates( GafferUI._qtAddress( w._qtWidget().model() ) ) + + self.assertEqual( + GafferSceneUI._InspectorColumn._dataFromPathListingOrReason( w ), + IECore.StringData( "sphere" ) + ) + + # A selection that only contains paths not represented by this + # PathListingWidget should be treated as no selection. + + w.setSelection( [ IECore.PathMatcher( [ "/notAPath" ] ) ] ) + _GafferUI._pathModelWaitForPendingUpdates( GafferUI._qtAddress( w._qtWidget().model() ) ) + + self.assertEqual( + GafferSceneUI._InspectorColumn._dataFromPathListingOrReason( w ), + "No selection" + ) diff --git a/python/GafferTest/SetExpressionAlgoTest.py b/python/GafferTest/SetExpressionAlgoTest.py old mode 100644 new mode 100755 index 438ed15261f..c1366548343 --- a/python/GafferTest/SetExpressionAlgoTest.py +++ b/python/GafferTest/SetExpressionAlgoTest.py @@ -764,7 +764,86 @@ def testExclude( self ) : testPaths.removePaths( Gaffer.SetExpressionAlgo.evaluateSetExpression( exclusions, s ) ) self.assertEqual( Gaffer.SetExpressionAlgo.evaluateSetExpression( excluded, s ), testPaths ) - def testIncludeAndExcludeSelf( self ) : + def testRemove( self ) : + + for base, removals, result in [ + ( "", "", "" ), + ( "A", "", "A" ), + ( "", "A", "" ), + ( "A", "A", "" ), + ( "A", "B", "A" ), + ( "A A", "A", "" ), + + ( "A B", "A", "B" ), + ( "A B", "B", "A" ), + ( "A B", "A B", "" ), + ( "A B", "B A", "" ), + ( "A B B", "B", "A" ), + ( "A B C", "B", "A C" ), + ( "A B C", "A C", "B" ), + ( "A B C", "A B C", "" ), + ( "A B C", "D", "A B C" ), + + ( "/a /b", "/a", "/b" ), + ( "A /b", "/b", "A" ), + ( "A /b", "A", "/b" ), + + # `-`, `in` and `containing` only remove from their left-hand side. + ( "A - B", "A", "" ), + ( "A - B", "B", "A - B" ), + ( "A - B", "C", "A - B" ), + ( "A B - C", "A", "B - C" ), + ( "A B - C", "B", "A" ), + ( "A B - C", "C", "A B - C" ), + ( "(A B) - C", "A", "B - C" ), + ( "(A B) - C", "B", "A - C" ), + ( "(A B) - C", "C", "(A B) - C" ), + ( "(A B) - C", "A B", "" ), + ( "(A B) - (C D)", "A", "B - (C D)" ), + ( "(A B) - (C D)", "C", "(A B) - (C D)" ), + ( "(A B) - (C D)", "A B", "" ), + + ( "A in B", "A", "" ), + ( "A in B", "B", "A in B" ), + ( "A in B", "C", "A in B" ), + ( "(A B) in C", "A", "B in C" ), + ( "(A B) in C", "B", "A in C" ), + ( "(A B) in C", "C", "(A B) in C" ), + ( "(A B) in C", "A B", "" ), + + ( "A containing B", "A", "" ), + ( "A containing B", "B", "A containing B" ), + ( "(A B) containing C", "A", "B containing C" ), + ( "(A B) containing C", "B", "A containing C" ), + ( "(A B) containing C", "C", "(A B) containing C" ), + ( "(A B) containing C", "A B", "" ), + + ( "A & B", "A", "" ), + ( "A & B", "B", "" ), + ( "A & B", "C", "A & B" ), + ( "A & B", "A B", "" ), + ( "A & B", "A & B", "" ), + ( "A & B C", "A", "C" ), + ( "A & B C", "C", "A & B" ), + ( "A & B C", "A B", "C" ), + ( "A & B C", "A & B", "C" ), + + # Wildcard tokens are matched literally. + ( "A A*", "A", "A*" ), + ( "A A*", "A*", "A" ), + ( "A*", "A", "A*" ), + + ] : + with self.subTest( base = base, removals = removals, result = result ) : + + removed = Gaffer.SetExpressionAlgo.remove( base, removals ) + self.assertEqual( removed, result ) + # The new set expression should be already simplified. + self.assertEqual( removed, Gaffer.SetExpressionAlgo.simplify( removed ) ) + # Removing `removals` a second time should result in no change to the expression. + self.assertEqual( removed, Gaffer.SetExpressionAlgo.remove( removed, removals ) ) + + def testIncludeExcludeAndRemoveSelf( self ) : for expression in ( "", @@ -801,3 +880,13 @@ def testIncludeAndExcludeSelf( self ) : with self.subTest( expression = expression ) : self.assertEqual( Gaffer.SetExpressionAlgo.exclude( expression, expression ), "" ) self.assertEqual( Gaffer.SetExpressionAlgo.include( expression, expression ), Gaffer.SetExpressionAlgo.simplify( expression ) ) + self.assertEqual( Gaffer.SetExpressionAlgo.remove( expression, expression ), "" ) + + def testEmptyAndWhitespaceEdits( self ) : + + for expression in ( "", "A", "A B", "A - B", "A & B", "A in B" ) : + for edit in ( "", " ", "\t", "\n", " \t\n " ) : + with self.subTest( expression = expression, edit = edit ) : + self.assertEqual( Gaffer.SetExpressionAlgo.include( expression, edit ), expression ) + self.assertEqual( Gaffer.SetExpressionAlgo.exclude( expression, edit ), expression ) + self.assertEqual( Gaffer.SetExpressionAlgo.remove( expression, edit ), expression ) diff --git a/python/GafferUI/PathListingWidget.py b/python/GafferUI/PathListingWidget.py index 538ac61391c..0a18cd5976f 100644 --- a/python/GafferUI/PathListingWidget.py +++ b/python/GafferUI/PathListingWidget.py @@ -775,7 +775,7 @@ def __dragBegin( self, widget, event ) : if selection[0].match( str( path ) ) & IECore.PathMatcher.Result.ExactMatch : GafferUI.Pointer.setCurrent( self.__dragPointer ) - return IECore.StringVectorData( selection[0].paths() ) + return IECore.StringVectorData( self.visualOrder( selection[0] ) ) index = self.__indexAt( event.line.p0 ) if index is not None : diff --git a/python/GafferUI/PlugLayout.py b/python/GafferUI/PlugLayout.py index 7f50d02ec88..b79c56635f1 100644 --- a/python/GafferUI/PlugLayout.py +++ b/python/GafferUI/PlugLayout.py @@ -319,8 +319,10 @@ def __updateLayout( self ) : self.__widgets[item] = widget else : widget = self.__widgets[item] - if self.__itemMetadataValue( item, "width" ) : - widget._qtWidget().setFixedWidth( self.__itemMetadataValue( item, "width" ) ) + self.__setWidthFromMetadata( + widget.plugValueWidget() if isinstance( widget, GafferUI.PlugWidget ) else widget, + item + ) if widget is None : continue diff --git a/python/GafferUI/_StyleSheet.py b/python/GafferUI/_StyleSheet.py index 01e362be7e1..e2368022577 100644 --- a/python/GafferUI/_StyleSheet.py +++ b/python/GafferUI/_StyleSheet.py @@ -1314,6 +1314,7 @@ def styleColor( key ) : *[gafferClass="GafferSceneUI.AttributeEditor"] QTreeView::item, *[gafferClass="GafferSceneUI.SceneInspector"] QTreeView::item, *[gafferClass="GafferSceneUI._HistoryWindow"] QTreeView::item, + *[gafferClass="GafferSceneUI.LightLinkingEditor"] QTreeView::item, *[gafferClass="GafferSceneUI.SetEditor"] QTreeView::item { height: 20px; padding-top: 0px; diff --git a/python/GafferUITest/PlugLayoutTest.py b/python/GafferUITest/PlugLayoutTest.py index cff9a1688a7..bb5c6f8ce02 100644 --- a/python/GafferUITest/PlugLayoutTest.py +++ b/python/GafferUITest/PlugLayoutTest.py @@ -411,3 +411,74 @@ def iNameFilterFunction( plug ) : self.assertTrue( l.plugValueWidget( n["i2"] ).visible() ) self.assertTrue( l.plugValueWidget( n["f"] ).visible() ) self.assertTrue( l.customWidget( "test" ).visible() ) + + def testWidthMetadata( self ) : + + n = Gaffer.Node() + n["withLabel"] = Gaffer.IntPlug() + n["withoutLabel"] = Gaffer.IntPlug() + + Gaffer.Metadata.registerValue( n["withLabel"], "layout:width", 100 ) + Gaffer.Metadata.registerValue( n["withoutLabel"], "layout:width", 100 ) + Gaffer.Metadata.registerValue( n["withoutLabel"], "label", "" ) + + l = GafferUI.PlugLayout( n, orientation = GafferUI.ListContainer.Orientation.Horizontal ) + + withLabel = l.plugValueWidget( n["withLabel"] ) + self.assertEqual( withLabel._qtWidget().minimumWidth(), 100 ) + self.assertEqual( withLabel._qtWidget().maximumWidth(), 100 ) + + plugWidget = withLabel.ancestor( GafferUI.PlugWidget ) + self.assertIsNotNone( plugWidget ) + self.assertNotEqual( plugWidget._qtWidget().maximumWidth(), 100 ) + + withoutLabel = l.plugValueWidget( n["withoutLabel"] ) + self.assertIsNone( withoutLabel.ancestor( GafferUI.PlugWidget ) ) + self.assertEqual( withoutLabel._qtWidget().minimumWidth(), 100 ) + self.assertEqual( withoutLabel._qtWidget().maximumWidth(), 100 ) + + def testWidthMetadataMaintainedByRelayout( self ) : + + n = Gaffer.Node() + n["withLabel"] = Gaffer.IntPlug() + n["withoutLabel"] = Gaffer.IntPlug() + n["other"] = Gaffer.IntPlug() + + Gaffer.Metadata.registerValue( n["withLabel"], "layout:width", 100 ) + Gaffer.Metadata.registerValue( n["withoutLabel"], "label", "" ) + + l = GafferUI.PlugLayout( n, orientation = GafferUI.ListContainer.Orientation.Horizontal ) + + withLabel = l.plugValueWidget( n["withLabel"] ) + withLabelPlugWidget = withLabel.ancestor( GafferUI.PlugWidget ) + self.assertIsNotNone( withLabelPlugWidget ) + + self.assertEqual( withLabel._qtWidget().minimumWidth(), 100 ) + self.assertEqual( withLabel._qtWidget().maximumWidth(), 100 ) + + plugWidgetMin = withLabelPlugWidget._qtWidget().minimumWidth() + plugWidgetMax = withLabelPlugWidget._qtWidget().maximumWidth() + self.assertLess( plugWidgetMin, 100 ) + self.assertGreater( plugWidgetMax, 100 ) + + # A width change on any plug causes an update of the entire PlugLayout, + # reusing the existing widgets. Ensure widths are applied to the same + # widgets they were applied to originally. + + Gaffer.Metadata.registerValue( n["other"], "layout:width", 50 ) + Gaffer.Metadata.registerValue( n["withoutLabel"], "layout:width", 50 ) + + self.assertIs( l.plugValueWidget( n["withLabel"] ), withLabel ) + self.assertEqual( withLabel._qtWidget().minimumWidth(), 100 ) + self.assertEqual( withLabel._qtWidget().maximumWidth(), 100 ) + self.assertEqual( withLabelPlugWidget._qtWidget().minimumWidth(), plugWidgetMin ) + self.assertEqual( withLabelPlugWidget._qtWidget().maximumWidth(), plugWidgetMax ) + + withoutLabel = l.plugValueWidget( n["withoutLabel"] ) + self.assertIsNone( withoutLabel.ancestor( GafferUI.PlugWidget ) ) + self.assertEqual( withoutLabel._qtWidget().minimumWidth(), 50 ) + self.assertEqual( withoutLabel._qtWidget().maximumWidth(), 50 ) + + other = l.plugValueWidget( n["other"] ) + self.assertEqual( other._qtWidget().minimumWidth(), 50 ) + self.assertEqual( other._qtWidget().maximumWidth(), 50 ) diff --git a/resources/graphics.py b/resources/graphics.py index 8be82a3bd29..69f997c273a 100644 --- a/resources/graphics.py +++ b/resources/graphics.py @@ -554,6 +554,19 @@ }, + "lightLinkingEditor" : { + + "options" : { + "validatePixelAlignment" : True, + }, + + "ids" : [ + "link", + "unlink", + ], + + }, + }, "ids" : [ diff --git a/resources/graphics.svg b/resources/graphics.svg index 49666cec669..1187d04990e 100644 --- a/resources/graphics.svg +++ b/resources/graphics.svg @@ -1831,6 +1831,29 @@ id="rect2561-1" />Node Editor + + LightLinkingEditor + + + + + + + + + + + + + + + + + + + diff --git a/src/Gaffer/SetExpressionAlgo.cpp b/src/Gaffer/SetExpressionAlgo.cpp old mode 100644 new mode 100755 index 85ddce756f8..f58dc686c79 --- a/src/Gaffer/SetExpressionAlgo.cpp +++ b/src/Gaffer/SetExpressionAlgo.cpp @@ -540,16 +540,12 @@ struct SimplifyVisitor }; // Removes the ops in `removalsAst` from the visited AST. -/// \todo This could be exposed as -/// SetExpressionAlgo::remove( const std::string &setExpression, const std::string &removals )` -/// to provide a more robust way of performing operations such as drag-and-drop removal of -/// set names in SetFilterUI or _InspectorColumn. When doing this we'd likely want a mode -/// that does not remove from the RHS of Difference ops, as this currently does. struct RemovalVisitor { using result_type = ExpressionAst; - RemovalVisitor( const ExpressionAst &removalsAst ) + RemovalVisitor( const ExpressionAst &removalsAst, bool removeFromDifferenceRHS = true ) + : m_removeFromDifferenceRHS( removeFromDifferenceRHS ) { std::vector ops; collectOperands( removalsAst, Union, ops ); @@ -580,7 +576,7 @@ struct RemovalVisitor return Nil{}; } - if( expr.op == Containing || expr.op == In ) + if( expr.op == Containing || expr.op == In || ( expr.op == Difference && !m_removeFromDifferenceRHS ) ) { // Removals only affect the left side of these operations. return BinaryOp( filteredLeft, expr.op, expr.right ); @@ -637,6 +633,7 @@ struct RemovalVisitor } boost::container::flat_set m_removals; + bool m_removeFromDifferenceRHS; }; @@ -986,6 +983,12 @@ ExpressionAst excludeExpression( const ExpressionAst &ast, const ExpressionAst & // before we subtract `exclusions` from `ast` and simplify. This ensures the exclusions // remain on the right-hand side of the final expression after simplification. ExpressionAst simplifiedExclusions = simplifyExpression( exclusions ); + if( boost::get( &simplifiedExclusions ) ) + { + // Nothing to exclude. Return the simplified input ast instead of the invalid "filteredAst - Nil". + return simplifyExpression( ast ); + } + ExpressionAst filteredAst = boost::apply_visitor( RemovalVisitor( simplifiedExclusions ), simplifyExpression( ast ) ); if( boost::get( &filteredAst ) ) @@ -997,6 +1000,15 @@ ExpressionAst excludeExpression( const ExpressionAst &ast, const ExpressionAst & return simplifyExpression( BinaryOp( filteredAst, Difference, simplifiedExclusions ) ); } +ExpressionAst removeExpression( const ExpressionAst &ast, const ExpressionAst &removals ) +{ + ExpressionAst filteredAst = boost::apply_visitor( + RemovalVisitor( simplifyExpression( removals ), /* removeFromDifferenceRHS = */ false ), simplifyExpression( ast ) + ); + + return simplifyExpression( filteredAst ); +} + } // namespace namespace Gaffer @@ -1065,6 +1077,21 @@ std::string exclude( const std::string &setExpression, const std::string &exclus return boost::apply_visitor( AstSerialiser{}, excludeExpression( ast, exclusionsAst ) ); } +std::string remove( const std::string &setExpression, const std::string &removals ) +{ + if( removals == "" ) + { + return setExpression; + } + + ExpressionAst ast; + ExpressionAst removalsAst; + expressionToAST( setExpression, ast ); + expressionToAST( removals, removalsAst ); + + return boost::apply_visitor( AstSerialiser{}, removeExpression( ast, removalsAst ) ); +} + } // namespace SetExpressionAlgo } // namespace Gaffer diff --git a/src/GafferModule/SetExpressionAlgoBinding.cpp b/src/GafferModule/SetExpressionAlgoBinding.cpp old mode 100644 new mode 100755 index 2361ff3487e..5366d00cd0c --- a/src/GafferModule/SetExpressionAlgoBinding.cpp +++ b/src/GafferModule/SetExpressionAlgoBinding.cpp @@ -128,6 +128,7 @@ void bindSetExpressionAlgo() def( "simplify", &Gaffer::SetExpressionAlgo::simplify ); def( "include", &Gaffer::SetExpressionAlgo::include ); def( "exclude", &Gaffer::SetExpressionAlgo::exclude ); + def( "remove", &Gaffer::SetExpressionAlgo::remove ); } } // namespace GafferModule diff --git a/src/GafferSceneUIModule/LightEditorBinding.cpp b/src/GafferSceneUIModule/LightEditorBinding.cpp index 9607e0981bf..55e9cee115a 100644 --- a/src/GafferSceneUIModule/LightEditorBinding.cpp +++ b/src/GafferSceneUIModule/LightEditorBinding.cpp @@ -89,8 +89,8 @@ class LocationNameColumn : public StandardPathColumn IE_CORE_DECLAREMEMBERPTR( LocationNameColumn ) - LocationNameColumn() - : StandardPathColumn( "Name", "name" ) + LocationNameColumn( PathColumn::SizeMode sizeMode ) + : StandardPathColumn( "Name", "name", sizeMode ) { } @@ -377,7 +377,7 @@ void GafferSceneUIModule::bindLightEditor() { IECorePython::RefCountedClass( "_LightEditorLocationNameColumn" ) - .def( init<>() ) + .def( init( arg_( "sizeMode" ) = PathColumn::Default ) ) ; IECorePython::RefCountedClass( "_LightEditorMuteColumn" ) diff --git a/startup/gui/graphEditor.py b/startup/gui/graphEditor.py index 4f00bc3c101..7591e099350 100644 --- a/startup/gui/graphEditor.py +++ b/startup/gui/graphEditor.py @@ -261,7 +261,7 @@ def __dropLocationData( event ) : scene = sourceEditor.view()["in"].getInput() elif isinstance( sourceEditor, - ( GafferSceneUI.HierarchyView, GafferSceneUI.LightEditor, GafferSceneUI.AttributeEditor ) + ( GafferSceneUI.HierarchyView, GafferSceneUI.LightEditor, GafferSceneUI.AttributeEditor, GafferSceneUI.LightLinkingEditor ) ) : scene = sourceEditor.settings()["in"].getInput() diff --git a/startup/gui/layouts.py b/startup/gui/layouts.py index 41b1a542cef..dbfb4228da6 100644 --- a/startup/gui/layouts.py +++ b/startup/gui/layouts.py @@ -58,6 +58,7 @@ layouts.registerEditor( "ImageInspector") layouts.registerEditor( "RenderPassEditor" ) layouts.registerEditor( "AttributeEditor" ) +layouts.registerEditor( "LightLinkingEditor" ) # Register some predefined layouts # @@ -71,10 +72,10 @@ # > this file, to prevent the standard layouts from being serialised into the user's own # > preferences. -layouts.add( 'Standard', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Vertical, 0.974512743628186, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.699764982373678, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.4799382716049383, ( {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.RenderPassEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.5393518518518519, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode ), GafferSceneUI.SetEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferImageUI.ImageInspector( scriptNode ), GafferUI.PythonEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), 'detachedPanels' : (), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0.243554682, 0.176846594 ), imath.V2f( 0.627929688, 0.858664751 ) ) }, 'editorState' : {'c-0-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-3': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-5': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) -layouts.add( 'Standard (multi-monitor)', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Vertical, 0.974512743628186, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.699764982373678, ( {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.RenderPassEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, ( GafferUI.SplitContainer.Orientation.Vertical, 0.5393518518518519, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode ), GafferSceneUI.SetEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferImageUI.ImageInspector( scriptNode ), GafferUI.PythonEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), 'detachedPanels' : ( { 'children' : {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : False, 'bound' : imath.Box2f( imath.V2f( 0.104492188, 0.110085227 ), imath.V2f( 0.838867188, 0.904829562 ) ) } }, ), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0.243554682, 0.176846594 ), imath.V2f( 0.627929688, 0.858664751 ) ) }, 'editorState' : {'c-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-3': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-5': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'p-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'p-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) +layouts.add( 'Standard', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Vertical, 0.974512743628186, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.699764982373678, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.4799382716049383, ( {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.RenderPassEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode ), GafferSceneUI.LightLinkingEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.5393518518518519, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode ), GafferSceneUI.SetEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferImageUI.ImageInspector( scriptNode ), GafferUI.PythonEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), 'detachedPanels' : (), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0.243554682, 0.176846594 ), imath.V2f( 0.627929688, 0.858664751 ) ) }, 'editorState' : {'c-0-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-3': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-5': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-6': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) +layouts.add( 'Standard (multi-monitor)', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Vertical, 0.974512743628186, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.699764982373678, ( {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.RenderPassEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode ), GafferSceneUI.LightLinkingEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, ( GafferUI.SplitContainer.Orientation.Vertical, 0.5393518518518519, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode ), GafferSceneUI.SetEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferImageUI.ImageInspector( scriptNode ), GafferUI.PythonEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), 'detachedPanels' : ( { 'children' : {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : False, 'bound' : imath.Box2f( imath.V2f( 0.104492188, 0.110085227 ), imath.V2f( 0.838867188, 0.904829562 ) ) } }, ), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0.243554682, 0.176846594 ), imath.V2f( 0.627929688, 0.858664751 ) ) }, 'editorState' : {'c-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-3': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-5': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-0-6': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'p-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'p-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) layouts.add( "Empty", "GafferUI.CompoundEditor( scriptNode, windowState = {'fullScreen': False, 'screen': -1, 'bound': imath.Box2f(imath.V2f(0.0479166657, 0.108269393), imath.V2f(0.782812476, 0.906223357)), 'maximized': True} )" ) -layouts.add( 'Scene', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Horizontal, 0.772425, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.255838, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( {'tabs': (GafferSceneUI.HierarchyView( scriptNode ),), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.SetEditor( scriptNode ),), 'currentTab': 0, 'tabsVisible': True} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.949025, ( {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( {'tabs': (GafferUI.NodeEditor( scriptNode ),), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.SceneInspector( scriptNode ),), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), 'detachedPanels' : (), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0, 0.377211601 ), imath.V2f( 0.384375006, 0.973814607 ) ) }, 'editorState' : {'c-0-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-4': {'nodeSet': 'scriptNode.focusSet()'}, 'c-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) +layouts.add( 'Scene', "GafferUI.CompoundEditor( scriptNode, _state={ 'children' : ( GafferUI.SplitContainer.Orientation.Horizontal, 0.772425, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.255838, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( {'tabs': (GafferSceneUI.HierarchyView( scriptNode ),), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.SetEditor( scriptNode ),), 'currentTab': 0, 'tabsVisible': True} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.949025, ( {'tabs': (GafferUI.Viewer( scriptNode ), GafferSceneUI.UVInspector( scriptNode ), GafferDispatchUI.LocalJobs( scriptNode )), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferUI.Timeline( scriptNode ),), 'currentTab': 0, 'tabsVisible': False} ) ), {'tabs': (GafferUI.GraphEditor( scriptNode ), GafferSceneUI.LightEditor( scriptNode ), GafferSceneUI.AttributeEditor( scriptNode ), GafferUI.AnimationEditor( scriptNode ), GafferSceneUI.PrimitiveInspector( scriptNode ), GafferSceneUI.LightLinkingEditor( scriptNode )), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.501120, ( {'tabs': (GafferUI.NodeEditor( scriptNode ),), 'currentTab': 0, 'tabsVisible': True}, {'tabs': (GafferSceneUI.SceneInspector( scriptNode ),), 'currentTab': 0, 'tabsVisible': True} ) ) ) ), 'detachedPanels' : (), 'windowState' : { 'screen' : -1, 'fullScreen' : False, 'maximized' : True, 'bound' : imath.Box2f( imath.V2f( 0, 0.377211601 ), imath.V2f( 0.384375006, 0.973814607 ) ) }, 'editorState' : {'c-0-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-0-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-0-0': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-0-0-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-1': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-2': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-4': {'nodeSet': 'scriptNode.focusSet()'}, 'c-0-1-1-0-5': {'nodeSet': 'scriptNode.focusSet()'}, 'c-1-1-0-0': {'nodeSet': 'scriptNode.focusSet()'}} } )" ) layouts.setDefault( "Standard" )