From 5c215cf44fb19f075ce04c4ff7130abc7db211d0 Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:54:47 -0700 Subject: [PATCH 01/11] HistoryWindow : Add icon for `CreateIfMissing` tweak mode --- Changes.md | 3 +++ python/GafferSceneUI/_HistoryWindow.py | 1 + resources/graphics.py | 1 + resources/graphics.svg | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/Changes.md b/Changes.md index e063673285d..797da2e9ba8 100644 --- a/Changes.md +++ b/Changes.md @@ -1,7 +1,10 @@ 1.3.16.x (relative to 1.3.16.6) ======== +Fixes +----- +- LightEditor, RenderPassEditor : Added missing icon representing use of the `CreateIfMissing` tweak mode in the history window. 1.3.16.6 (relative to 1.3.16.5) ======== diff --git a/python/GafferSceneUI/_HistoryWindow.py b/python/GafferSceneUI/_HistoryWindow.py index f4a5ddb65b2..5a6bbec8c99 100644 --- a/python/GafferSceneUI/_HistoryWindow.py +++ b/python/GafferSceneUI/_HistoryWindow.py @@ -63,6 +63,7 @@ def cellData( self, path, canceller = None ) : Gaffer.TweakPlug.Mode.Multiply : "multiplySmall.png", Gaffer.TweakPlug.Mode.Remove : "removeSmall.png", Gaffer.TweakPlug.Mode.Create : "createSmall.png", + Gaffer.TweakPlug.Mode.CreateIfMissing : "createIfMissingSmall.png", Gaffer.TweakPlug.Mode.Min : "lessThanSmall.png", Gaffer.TweakPlug.Mode.Max : "greaterThanSmall.png", Gaffer.TweakPlug.Mode.ListAppend : "listAppendSmall.png", diff --git a/resources/graphics.py b/resources/graphics.py index 09f73fa402b..14d5cc7b775 100644 --- a/resources/graphics.py +++ b/resources/graphics.py @@ -380,6 +380,7 @@ "multiplySmall", "replaceSmall", "createSmall", + "createIfMissingSmall", "lessThanSmall", "greaterThanSmall", "listAppendSmall", diff --git a/resources/graphics.svg b/resources/graphics.svg index 64f58dbe10a..1c913493038 100644 --- a/resources/graphics.svg +++ b/resources/graphics.svg @@ -3146,6 +3146,14 @@ width="14" style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:none;stroke-width:0.52291256;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:1.10173225;stroke-opacity:1;paint-order:markers stroke fill" inkscape:label="removeSmall" /> + + Date: Tue, 16 Jul 2024 14:45:27 -0400 Subject: [PATCH 02/11] ColorChooser : Add TMI Slider infrastructure --- SConstruct | 2 +- python/GafferUI/ColorChooser.py | 103 +++++++++++++++++++----- python/GafferUITest/ColorChooserTest.py | 89 ++++++++++++++++++++ python/GafferUITest/__init__.py | 1 + python/GafferUITest/images/tmi.exr | Bin 0 -> 17338 bytes 5 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 python/GafferUITest/ColorChooserTest.py create mode 100644 python/GafferUITest/images/tmi.exr diff --git a/SConstruct b/SConstruct index 89db06faf4e..b763a95cd43 100644 --- a/SConstruct +++ b/SConstruct @@ -1075,7 +1075,7 @@ libraries = { "GafferUITest" : { - "additionalFiles" : glob.glob( "python/GafferUITest/scripts/*.gfr" ), + "additionalFiles" : glob.glob( "python/GafferUITest/scripts/*.gfr" ) + glob.glob( "python/GafferUITest/images/*" ), }, diff --git a/python/GafferUI/ColorChooser.py b/python/GafferUI/ColorChooser.py index 18ed5cfaae4..03366f6b2c1 100644 --- a/python/GafferUI/ColorChooser.py +++ b/python/GafferUI/ColorChooser.py @@ -44,15 +44,46 @@ from Qt import QtGui +__tmiToRGBMatrix = imath.M33f( + -1.0 / 2.0, 0.0, 1.0 / 2.0, + 1.0 / 3.0, -2.0 / 3.0, 1.0 / 3.0, + 1.0, 1.0, 1.0 +) +__rgb2tmiMatrix = __tmiToRGBMatrix.inverse() + +def _tmiToRGB( c ) : + rgb = imath.V3f( c.r, c.g, c.b ) * __tmiToRGBMatrix + + result = c.__class__( c ) + result.r = rgb.x + result.g = rgb.y + result.b = rgb.z + + return result + +def _rgbToTMI( c ) : + tmi = imath.V3f( c.r, c.g, c.b ) * __rgb2tmiMatrix + + result = c.__class__( c ) + result.r = tmi.x + result.g = tmi.y + result.b = tmi.z + + return result + # A custom slider for drawing the backgrounds. class _ComponentSlider( GafferUI.Slider ) : def __init__( self, color, component, **kw ) : - min = hardMin = 0 - max = hardMax = 1 + if component in "tm" : + min = hardMin = -1 + max = hardMax = 1 + else : + min = hardMin = 0 + max = hardMax = 1 - if component in ( "r", "g", "b", "v" ) : + if component in ( "r", "g", "b", "v", "i" ) : hardMax = sys.float_info.max GafferUI.Slider.__init__( self, 0.0, min, max, hardMin, hardMax, **kw ) @@ -60,8 +91,8 @@ def __init__( self, color, component, **kw ) : self.color = color self.component = component - # Sets the slider color in RGB space for RGBA channels and - # HSV space for HSV channels. + # Sets the slider color in RGB space for RGBA channels, + # HSV space for HSV channels and TMI space for TMI channels. def setColor( self, color ) : self.color = color @@ -84,8 +115,8 @@ def _drawBackground( self, painter ) : else : c1 = imath.Color3f( self.color[0], self.color[1], self.color[2] ) c2 = imath.Color3f( self.color[0], self.color[1], self.color[2] ) - a = { "r" : 0, "g" : 1, "b" : 2, "h" : 0, "s" : 1, "v": 2 }[self.component] - c1[a] = 0 + a = { "r" : 0, "g" : 1, "b" : 2, "h" : 0, "s" : 1, "v": 2, "t" : 0, "m" : 1, "i" : 2 }[self.component] + c1[a] = -1 if self.component in "tm" else 0 c2[a] = 1 numStops = max( 2, size.x // 2 ) @@ -95,6 +126,8 @@ def _drawBackground( self, painter ) : c = c1 + (c2-c1) * t if self.component in "hsv" : c = c.hsv2rgb() + elif self.component in "tmi" : + c = _tmiToRGB( c ) grad.setColorAt( t, self._qtColor( displayTransform( c ) ) ) @@ -110,6 +143,8 @@ class ColorChooser( GafferUI.Widget ) : ColorChangedReason = enum.Enum( "ColorChangedReason", [ "Invalid", "SetColor", "Reset" ] ) + __ColorSpace = enum.Enum( "__ColorSpace", [ "RGB", "HSV", "TMI" ] ) + def __init__( self, color=imath.Color3f( 1 ), **kw ) : self.__column = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4 ) @@ -118,6 +153,7 @@ def __init__( self, color=imath.Color3f( 1 ), **kw ) : self.__color = color self.__colorHSV = self.__color.rgb2hsv() + self.__colorTMI = _rgbToTMI( self.__color ) self.__defaultColor = color self.__sliders = {} @@ -229,8 +265,11 @@ def __componentValueChanged( self, componentWidget, reason ) : # doesn't provide the capability itself. Add the functionality # into the NumericWidget and remove this code. componentValue = componentWidget.getValue() - componentValue = max( componentValue, 0 ) - if componentWidget.component in ( "a", "h", "s" ) : + if componentWidget.component in "tm" : + componentValue = max( componentValue, -1 ) + else : + componentValue = max( componentValue, 0 ) + if componentWidget.component in ( "a", "h", "s", "t", "m" ) : componentValue = min( componentValue, 1 ) if componentWidget.component in ( "r", "g", "b", "a" ) : @@ -240,15 +279,22 @@ def __componentValueChanged( self, componentWidget, reason ) : newColor[a] = componentValue self.__setColorInternal( newColor, reason ) - else : + elif componentWidget.component in ( "h", "s", "v" ) : newColor = self.__colorHSV.__class__( self.__colorHSV ) a = { "h" : 0, "s" : 1, "v" : 2 }[componentWidget.component] newColor[a] = componentValue - self.__setColorInternal( newColor, reason, True ) + self.__setColorInternal( newColor, reason, self.__ColorSpace.HSV ) + elif componentWidget.component in ( "t", "m", "i" ) : + newColor = self.__colorTMI.__class__( self.__colorTMI ) + + a = { "t" : 0, "m" : 1, "i" : 2 }[componentWidget.component] + newColor[a] = componentValue - def __setColorInternal( self, color, reason, hsv = False ) : + self.__setColorInternal( newColor, reason, self.__ColorSpace.TMI ) + + def __setColorInternal( self, color, reason, colorSpace = __ColorSpace.RGB ) : dragBeginOrEnd = reason in ( GafferUI.Slider.ValueChangedReason.DragBegin, @@ -257,19 +303,34 @@ def __setColorInternal( self, color, reason, hsv = False ) : GafferUI.NumericWidget.ValueChangedReason.DragEnd, ) - colorChanged = color != ( self.__colorHSV if hsv else self.__color ) + colorChanged = color != { + self.__ColorSpace.RGB : self.__color, + self.__ColorSpace.HSV : self.__colorHSV, + self.__ColorSpace.TMI : self.__colorTMI + }[colorSpace] if colorChanged : - colorRGB = color.hsv2rgb() if hsv else color - self.__color = colorRGB - self.__colorSwatch.setColor( colorRGB ) - - hsv = color if hsv else color.rgb2hsv() + if colorSpace == self.__ColorSpace.RGB : + colorRGB = color + colorHSV = color.rgb2hsv() + colorTMI = _rgbToTMI( colorRGB ) + elif colorSpace == self.__ColorSpace.HSV : + colorRGB = color.hsv2rgb() + colorHSV = color + colorTMI = _rgbToTMI( colorRGB ) + elif colorSpace == self.__ColorSpace.TMI : + colorRGB = _tmiToRGB( color ) + colorHSV = colorRGB.rgb2hsv() + colorTMI = color + + colorHSV[0] = colorHSV[0] if colorHSV[1] > 1e-7 and colorHSV[2] > 1e-7 else self.__colorHSV[0] + colorHSV[1] = colorHSV[1] if colorHSV[2] > 1e-7 else self.__colorHSV[1] - hsv[0] = hsv[0] if hsv[1] > 1e-7 and hsv[2] > 1e-7 else self.__colorHSV[0] - hsv[1] = hsv[1] if hsv[2] > 1e-7 else self.__colorHSV[1] + self.__color = colorRGB + self.__colorHSV = colorHSV + self.__colorTMI = colorTMI - self.__colorHSV = hsv + self.__colorSwatch.setColor( colorRGB ) ## \todo This is outside the conditional because the clamping we do # in __componentValueChanged means the color value may not correspond diff --git a/python/GafferUITest/ColorChooserTest.py b/python/GafferUITest/ColorChooserTest.py new file mode 100644 index 00000000000..dc333fd2e37 --- /dev/null +++ b/python/GafferUITest/ColorChooserTest.py @@ -0,0 +1,89 @@ +########################################################################## +# +# Copyright (c) 2024, 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 pathlib +import unittest + +import imath +import OpenImageIO + +from GafferUI.ColorChooser import _tmiToRGB +from GafferUI.ColorChooser import _rgbToTMI +import GafferUITest + +class ColorChooserTest( GafferUITest.TestCase ) : + + def testTMI( self ) : + # Load a precomputed image generated by Houdini to test our TMI <-> RGB calculation. + # The image is 200px x 20px. Each 20px square varies the temperature from -1.0 to 1.0 + # on the X axis and magenta from -1.0 to 1.0 on the Y-axis. Each of the 10 squares, from + # left to right, varies the intensity from 0.0 to 0.9. + tileWidth = 20 + tileCount = 10 + tmiTargetImage = OpenImageIO.ImageBuf( ( pathlib.Path( __file__ ).parent / "images" / "tmi.exr" ).as_posix() ) + + for tile in range ( 0, tileCount ) : + for y in range( 0, tileWidth ) : + for x in range( 0, tileWidth ) : + tmiOriginal = imath.Color3f( + ( float( x ) / tileWidth ) * 2.0 - 1.0, + ( float( y ) / tileWidth ) * 2.0 - 1.0, + float( tile ) / tileCount + ) + + rgbConverted = _tmiToRGB( tmiOriginal ) + tmiTarget = tmiTargetImage.getpixel( x + ( tile * tileWidth ), y ) + self.assertAlmostEqual( tmiTarget[0], rgbConverted.r, places = 6 ) + self.assertAlmostEqual( tmiTarget[1], rgbConverted.g, places = 6 ) + self.assertAlmostEqual( tmiTarget[2], rgbConverted.b, places = 6 ) + + tmiConverted = _rgbToTMI( rgbConverted ) + self.assertAlmostEqual( tmiConverted.r, tmiOriginal.r, places = 6 ) + self.assertAlmostEqual( tmiConverted.g, tmiOriginal.g, places = 6 ) + self.assertAlmostEqual( tmiConverted.b, tmiOriginal.b, places = 6 ) + + def testTMIAlpha( self ) : + + rgb = imath.Color4f( 0.5 ) + tmi = _rgbToTMI( rgb ) + self.assertEqual( tmi.a, rgb.a ) + + rgbConverted = _tmiToRGB( tmi ) + self.assertEqual( rgbConverted.a, rgb.a ) + + +if __name__ == "__main__" : + unittest.main() \ No newline at end of file diff --git a/python/GafferUITest/__init__.py b/python/GafferUITest/__init__.py index ad99a025b99..6e2635e70c1 100644 --- a/python/GafferUITest/__init__.py +++ b/python/GafferUITest/__init__.py @@ -133,6 +133,7 @@ from .BoxIOUITest import BoxIOUITest from .AnnotationsGadgetTest import AnnotationsGadgetTest from .PopupWindowTest import PopupWindowTest +from .ColorChooserTest import ColorChooserTest if __name__ == "__main__": unittest.main() diff --git a/python/GafferUITest/images/tmi.exr b/python/GafferUITest/images/tmi.exr new file mode 100644 index 0000000000000000000000000000000000000000..ec7eeb5aef281d16e8fb617c598475c539a9eb8c GIT binary patch literal 17338 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 Date: Mon, 15 Jul 2024 11:37:11 -0400 Subject: [PATCH 03/11] ColorChooser : Improve plug alignment This was broken slightly in #5902 when we introduced channel name labels. The text width varies somewhat, causing the numeric widget and sliders to be imperfectly aligned. --- python/GafferUI/ColorChooser.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/python/GafferUI/ColorChooser.py b/python/GafferUI/ColorChooser.py index 03366f6b2c1..607a8615ad8 100644 --- a/python/GafferUI/ColorChooser.py +++ b/python/GafferUI/ColorChooser.py @@ -158,23 +158,23 @@ def __init__( self, color=imath.Color3f( 1 ), **kw ) : self.__sliders = {} self.__numericWidgets = {} + self.__channelLabels = {} self.__componentValueChangedConnections = [] with self.__column : - # sliders and numeric widgets - for component in "rgbahsv" : - with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + with GafferUI.GridContainer( spacing = 4 ) : - with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 8 ) : - GafferUI.Label( component.capitalize() ) - numericWidget = GafferUI.NumericWidget( 0.0 ) + # sliders and numeric widgets + for row, component in enumerate( "rgbahsv" ) : + self.__channelLabels[component] = GafferUI.Label( component.capitalize(), parenting = { "index" : ( 0, row ), "alignment" : ( GafferUI.HorizontalAlignment.Center, GafferUI.VerticalAlignment.Center ) } ) + numericWidget = GafferUI.NumericWidget( 0.0, parenting = { "index" : ( 1, row ) } ) numericWidget.setFixedCharacterWidth( 6 ) numericWidget.component = component self.__numericWidgets[component] = numericWidget - slider = _ComponentSlider( color, component ) + slider = _ComponentSlider( color, component, parenting = { "index" : ( 2, row ) } ) self.__sliders[component] = slider self.__componentValueChangedConnections.append( @@ -360,9 +360,14 @@ def __updateUIFromColor( self ) : if c.dimensions() == 4 : self.__sliders["a"].setValue( c[3] ) self.__numericWidgets["a"].setValue( c[3] ) - self.__sliders["a"].parent().setVisible( True ) + + self.__sliders["a"].setVisible( True ) + self.__numericWidgets["a"].setVisible( True ) + self.__channelLabels["a"].setVisible( True ) else : - self.__sliders["a"].parent().setVisible( False ) + self.__sliders["a"].setVisible( False ) + self.__numericWidgets["a"].setVisible( False ) + self.__channelLabels["a"].setVisible( False ) for slider in [ v for k, v in self.__sliders.items() if k in "hsv" ] : slider.setColor( self.__colorHSV ) From cbb550d1e455147a37c119a57e97466d3c73940b Mon Sep 17 00:00:00 2001 From: John Haddon Date: Thu, 18 Jul 2024 11:04:47 +0100 Subject: [PATCH 04/11] ColorChooser : Allow negative values for RGBVTMI We don't really want to allow these, but they can occur naturally in one component while editing values in another. By accommodating that within the slider `hardMin`, we get a nice "out of range" triangle to alert us when we've gone negative, whereas before the slider clamped to zero and gave us a misleading value. For consistency, we now also allow negative values to be entered directly in the numeric widgets - if you can get into that state using a slider or numeric widget for another compoment, why preclude it in the field itself? --- python/GafferUI/ColorChooser.py | 48 +++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/python/GafferUI/ColorChooser.py b/python/GafferUI/ColorChooser.py index 607a8615ad8..66b92f56f78 100644 --- a/python/GafferUI/ColorChooser.py +++ b/python/GafferUI/ColorChooser.py @@ -35,6 +35,7 @@ # ########################################################################## +import collections import enum import sys import imath @@ -71,22 +72,39 @@ def _rgbToTMI( c ) : return result +__Range = collections.namedtuple( "__Range", [ "min", "max", "hardMin", "hardMax" ] ) + +_ranges = { + # We don't _really_ want to allow negative values for RGB, but + # they can arise from TMI values in the allowed TMI range. It's + # better to allow these to be displayed (as an "out of range" + # triangle in the sliders) than to show inconsistent values between + # components. + "r" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ), + "g" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ), + "b" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ), + "a" : __Range( 0, 1, 0, 1 ), + "h" : __Range( 0, 1, 0, 1 ), + "s" : __Range( 0, 1, 0, 1 ), + # As above, we're allowing out-of-regular-range values here too, + # because they can arise when authoring in-range values via RGB. + "v" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ), + "t" : __Range( -1, 1, -sys.float_info.max, sys.float_info.max ), + "m" : __Range( -1, 1, -sys.float_info.max, sys.float_info.max ), + "i" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ), +} + # A custom slider for drawing the backgrounds. class _ComponentSlider( GafferUI.Slider ) : def __init__( self, color, component, **kw ) : - if component in "tm" : - min = hardMin = -1 - max = hardMax = 1 - else : - min = hardMin = 0 - max = hardMax = 1 - - if component in ( "r", "g", "b", "v", "i" ) : - hardMax = sys.float_info.max - - GafferUI.Slider.__init__( self, 0.0, min, max, hardMin, hardMax, **kw ) + GafferUI.Slider.__init__( + self, 0.0, + min = _ranges[component].min, max = _ranges[component].max, + hardMin = _ranges[component].hardMin, hardMax = _ranges[component].hardMax, + **kw + ) self.color = color self.component = component @@ -265,12 +283,8 @@ def __componentValueChanged( self, componentWidget, reason ) : # doesn't provide the capability itself. Add the functionality # into the NumericWidget and remove this code. componentValue = componentWidget.getValue() - if componentWidget.component in "tm" : - componentValue = max( componentValue, -1 ) - else : - componentValue = max( componentValue, 0 ) - if componentWidget.component in ( "a", "h", "s", "t", "m" ) : - componentValue = min( componentValue, 1 ) + componentValue = max( componentValue, _ranges[componentWidget.component].hardMin ) + componentValue = min( componentValue, _ranges[componentWidget.component].hardMax ) if componentWidget.component in ( "r", "g", "b", "a" ) : newColor = self.__color.__class__( self.__color ) From bfdada265995ab4f1c604661597dfcb0db100141 Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:03:56 -0700 Subject: [PATCH 05/11] AttributeInspector : Implement fallbackValue to return inherited values This enables us to fall back to inspecting the values of inherited attributes, using the fallbackValue approach already used by OptionInspector for default option values registered as metadata. The initial use-case for this is in the LightEditor, but this would also be of use in a future where we include columns displaying attribute values in the Hierarchy View and other Editors. --- Changes.md | 3 ++ .../Private/AttributeInspector.h | 1 + include/GafferSceneUI/Private/Inspector.h | 2 +- .../GafferSceneUI/Private/OptionInspector.h | 2 +- .../Private/ParameterInspector.h | 1 + .../AttributeInspectorTest.py | 32 +++++++++++++++++++ src/GafferSceneUI/AttributeInspector.cpp | 5 +++ src/GafferSceneUI/Inspector.cpp | 4 +-- src/GafferSceneUI/OptionInspector.cpp | 2 +- src/GafferSceneUI/ParameterInspector.cpp | 6 ++++ 10 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Changes.md b/Changes.md index 543f5273c92..a8dfcbc5e20 100644 --- a/Changes.md +++ b/Changes.md @@ -1,7 +1,10 @@ 1.4.x.x (relative to 1.4.9.0) ======= +Improvements +------------ +- LightEditor : Values of inherited attributes are now displayed in the Light Editor. These are presented as dimmed "fallback" values. 1.4.9.0 (relative to 1.4.8.0) ======= diff --git a/include/GafferSceneUI/Private/AttributeInspector.h b/include/GafferSceneUI/Private/AttributeInspector.h index 2cf30971144..920cca95253 100644 --- a/include/GafferSceneUI/Private/AttributeInspector.h +++ b/include/GafferSceneUI/Private/AttributeInspector.h @@ -67,6 +67,7 @@ class GAFFERSCENEUI_API AttributeInspector : public Inspector IECore::ConstObjectPtr value( const GafferScene::SceneAlgo::History *history) const override; Gaffer::ValuePlugPtr source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const override; EditFunctionOrFailure editFunction( Gaffer::EditScope *scope, const GafferScene::SceneAlgo::History *history) const override; + IECore::ConstObjectPtr fallbackValue( const GafferScene::SceneAlgo::History *history ) const override; bool attributeExists() const; diff --git a/include/GafferSceneUI/Private/Inspector.h b/include/GafferSceneUI/Private/Inspector.h index 48fc2293e95..c0c1d8e46e4 100644 --- a/include/GafferSceneUI/Private/Inspector.h +++ b/include/GafferSceneUI/Private/Inspector.h @@ -179,7 +179,7 @@ class GAFFERSCENEUI_API Inspector : public IECore::RefCounted, public Gaffer::Si /// Can be implemented by derived classes to provide a fallback value for the inspection, /// used when no value is returned from `value()`. - virtual IECore::ConstObjectPtr fallbackValue() const; + virtual IECore::ConstObjectPtr fallbackValue( const GafferScene::SceneAlgo::History *history ) const; protected : diff --git a/include/GafferSceneUI/Private/OptionInspector.h b/include/GafferSceneUI/Private/OptionInspector.h index aaf3db525dc..617ee73e803 100644 --- a/include/GafferSceneUI/Private/OptionInspector.h +++ b/include/GafferSceneUI/Private/OptionInspector.h @@ -65,7 +65,7 @@ class GAFFERSCENEUI_API OptionInspector : public Inspector IECore::ConstObjectPtr value( const GafferScene::SceneAlgo::History *history ) const override; Gaffer::ValuePlugPtr source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const override; EditFunctionOrFailure editFunction( Gaffer::EditScope *scope, const GafferScene::SceneAlgo::History *history ) const override; - IECore::ConstObjectPtr fallbackValue() const override; + IECore::ConstObjectPtr fallbackValue( const GafferScene::SceneAlgo::History *history ) const override; private : diff --git a/include/GafferSceneUI/Private/ParameterInspector.h b/include/GafferSceneUI/Private/ParameterInspector.h index a63d6a062c1..97ac7e992d3 100644 --- a/include/GafferSceneUI/Private/ParameterInspector.h +++ b/include/GafferSceneUI/Private/ParameterInspector.h @@ -69,6 +69,7 @@ class GAFFERSCENEUI_API ParameterInspector : public AttributeInspector IECore::ConstObjectPtr value( const GafferScene::SceneAlgo::History *history ) const override; Gaffer::ValuePlugPtr source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const override; EditFunctionOrFailure editFunction( Gaffer::EditScope *editScope, const GafferScene::SceneAlgo::History *history ) const override; + IECore::ConstObjectPtr fallbackValue( const GafferScene::SceneAlgo::History *history ) const override; const IECoreScene::ShaderNetwork::Parameter m_parameter; diff --git a/python/GafferSceneUITest/AttributeInspectorTest.py b/python/GafferSceneUITest/AttributeInspectorTest.py index 85d4fb0e134..a76e3fe255d 100644 --- a/python/GafferSceneUITest/AttributeInspectorTest.py +++ b/python/GafferSceneUITest/AttributeInspectorTest.py @@ -116,6 +116,38 @@ def testValue( self ) : IECore.FloatData( 2.0 ) ) + def testFallbackValue( self ) : + + light = GafferSceneTest.TestLight() + group = GafferScene.Group() + group["in"][0].setInput( light["out"] ) + + groupFilter = GafferScene.PathFilter() + groupFilter["paths"].setValue( IECore.StringVectorData( [ "/group" ] ) ) + + glAttributes = GafferScene.OpenGLAttributes() + glAttributes["in"].setInput( group["out"] ) + glAttributes["filter"].setInput( groupFilter["out"] ) + glAttributes["attributes"]["visualiserScale"]["enabled"].setValue( True ) + glAttributes["attributes"]["visualiserScale"]["value"].setValue( 2.0 ) + + # With no "gl:visualiser:scale" attribute at /group/light, the inspection returns + # the inherited attribute value with `sourceType` identifying it as a fallback. + + inspection = self.__inspect( glAttributes["out"], "/group/light", "gl:visualiser:scale" ) + self.assertEqual( inspection.value(), IECore.FloatData( 2.0 ) ) + self.assertEqual( inspection.sourceType(), GafferSceneUI.Private.Inspector.Result.SourceType.Fallback ) + + # With a "gl:visualiser:scale" attribute created at the inspected location, it is + # returned instead of the inherited fallback. + + light["visualiserAttributes"]["scale"]["enabled"].setValue( True ) + light["visualiserAttributes"]["scale"]["value"].setValue( 4.0 ) + + inspection = self.__inspect( glAttributes["out"], "/group/light", "gl:visualiser:scale" ) + self.assertEqual( inspection.value(), IECore.FloatData( 4.0 ) ) + self.assertEqual( inspection.sourceType(), GafferSceneUI.Private.Inspector.Result.SourceType.Other ) + def testSourceAndEdits( self ) : s = Gaffer.ScriptNode() diff --git a/src/GafferSceneUI/AttributeInspector.cpp b/src/GafferSceneUI/AttributeInspector.cpp index 9a5e20b6052..34b2e18fc40 100644 --- a/src/GafferSceneUI/AttributeInspector.cpp +++ b/src/GafferSceneUI/AttributeInspector.cpp @@ -245,6 +245,11 @@ IECore::ConstObjectPtr AttributeInspector::value( const GafferScene::SceneAlgo:: return nullptr; } +IECore::ConstObjectPtr AttributeInspector::fallbackValue( const GafferScene::SceneAlgo::History *history ) const +{ + return history->scene->fullAttributes( history->context->get( ScenePlug::scenePathContextName ) )->member( m_attribute ); +} + Gaffer::ValuePlugPtr AttributeInspector::source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const { auto sceneNode = runTimeCast( history->scene->node() ); diff --git a/src/GafferSceneUI/Inspector.cpp b/src/GafferSceneUI/Inspector.cpp index 63a4c53ccd9..4de806889d1 100644 --- a/src/GafferSceneUI/Inspector.cpp +++ b/src/GafferSceneUI/Inspector.cpp @@ -193,7 +193,7 @@ Inspector::ResultPtr Inspector::inspect() const bool fallbackValue = false; if( !value ) { - value = this->fallbackValue(); + value = this->fallbackValue( history.get() ); fallbackValue = (bool)value; } @@ -365,7 +365,7 @@ Inspector::EditFunctionOrFailure Inspector::editFunction( Gaffer::EditScope *edi return "Editing not supported"; } -IECore::ConstObjectPtr Inspector::fallbackValue() const +IECore::ConstObjectPtr Inspector::fallbackValue( const GafferScene::SceneAlgo::History *history ) const { return nullptr; } diff --git a/src/GafferSceneUI/OptionInspector.cpp b/src/GafferSceneUI/OptionInspector.cpp index ea1ae2aaa52..579c6cb55be 100644 --- a/src/GafferSceneUI/OptionInspector.cpp +++ b/src/GafferSceneUI/OptionInspector.cpp @@ -215,7 +215,7 @@ IECore::ConstObjectPtr OptionInspector::value( const GafferScene::SceneAlgo::His return nullptr; } -IECore::ConstObjectPtr OptionInspector::fallbackValue() const +IECore::ConstObjectPtr OptionInspector::fallbackValue( const GafferScene::SceneAlgo::History *history ) const { if( const auto defaultValue = Gaffer::Metadata::value( g_optionPrefix + m_option.string(), g_defaultValue ) ) { diff --git a/src/GafferSceneUI/ParameterInspector.cpp b/src/GafferSceneUI/ParameterInspector.cpp index 72376b8c935..f35ae77d427 100644 --- a/src/GafferSceneUI/ParameterInspector.cpp +++ b/src/GafferSceneUI/ParameterInspector.cpp @@ -98,6 +98,12 @@ IECore::ConstObjectPtr ParameterInspector::value( const GafferScene::SceneAlgo:: return shader->parametersData()->member( m_parameter.name ); } +IECore::ConstObjectPtr ParameterInspector::fallbackValue( const GafferScene::SceneAlgo::History *history ) const +{ + // No fallback values are provided for parameters. Implemented to override AttributeInspector::fallbackValue(). + return nullptr; +} + Gaffer::ValuePlugPtr ParameterInspector::source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const { auto sceneNode = runTimeCast( history->scene->node() ); From 41c0d936ebb7fe6599f3c3b4c29fa129b3d87135 Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:06:58 -0700 Subject: [PATCH 06/11] LightEditor : Update MuteColumn cellData to use Inspector fallbackValue With the AttributeInspector now providing inherited `light:mute` attribute values as a fallback, we can use it to determine which mute icon to display for a path based on the inspected value and the inspection sourceType. Though for paths inheriting a `light:mute` attribute we still need to walk up the hierarchy to find the location inherited from in order to display it in the tooltip... --- .../LightEditorBinding.cpp | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/GafferSceneUIModule/LightEditorBinding.cpp b/src/GafferSceneUIModule/LightEditorBinding.cpp index dec64e0dc77..292bedb2b25 100644 --- a/src/GafferSceneUIModule/LightEditorBinding.cpp +++ b/src/GafferSceneUIModule/LightEditorBinding.cpp @@ -191,7 +191,7 @@ class InspectorColumn : public PathColumn m_inspector->dirtiedSignal().connect( boost::bind( &InspectorColumn::inspectorDirtied, this ) ); } - GafferSceneUI::Private::Inspector *inspector() + GafferSceneUI::Private::Inspector *inspector() const { return m_inspector.get(); } @@ -292,39 +292,46 @@ class MuteColumn : public InspectorColumn if( auto value = runTimeCast( result.value ) ) { - result.icon = value->readable() ? m_muteIconData : m_unMuteIconData; - } - else - { - ScenePlug::PathScope pathScope( scenePath->getContext() ); - ScenePlug::ScenePath currentPath( scenePath->names() ); - while( !currentPath.empty() ) + ScenePlug::PathScope pathScope( scenePath->getContext(), &scenePath->names() ); + pathScope.setCanceller( canceller ); + + Inspector::ConstResultPtr inspectorResult = inspector()->inspect(); + if( inspectorResult->sourceType() != Inspector::Result::SourceType::Fallback ) { - pathScope.setPath( ¤tPath ); - auto a = scenePath->getScene()->attributesPlug()->getValue(); - if( auto fullValue = a->member( "light:mute" ) ) - { - result.icon = fullValue->readable() ? m_muteFadedIconData : m_unMuteFadedIconData; - result.toolTip = new StringData( "Inherited from : " + ScenePlug::pathToString( currentPath ) ); - break; - } - currentPath.pop_back(); + result.icon = value->readable() ? m_muteIconData : m_unMuteIconData; } - if( !result.icon ) + else { - // Use a transparent icon to reserve space in the UI. Without this, - // the top row will resize when setting the mute value, causing a full - // table resize. - if( path.isEmpty() ) - { - result.icon = m_muteBlankIconName; - } - else + result.icon = value->readable() ? m_muteFadedIconData : m_unMuteFadedIconData; + + ScenePlug::ScenePath currentPath( scenePath->names() ); + while( !currentPath.empty() ) { - result.icon = m_muteUndefinedIconData; + pathScope.setPath( ¤tPath ); + auto a = scenePath->getScene()->attributesPlug()->getValue(); + if( a->member( "light:mute" ) ) + { + result.toolTip = new StringData( "Inherited from : " + ScenePlug::pathToString( currentPath ) ); + break; + } + currentPath.pop_back(); } } } + if( !result.icon ) + { + // Use a transparent icon to reserve space in the UI. Without this, + // the top row will resize when setting the mute value, causing a full + // table resize. + if( path.isEmpty() ) + { + result.icon = m_muteBlankIconName; + } + else + { + result.icon = m_muteUndefinedIconData; + } + } result.value = nullptr; if( auto toolTipData = runTimeCast( result.toolTip ) ) From 830926a2976c0be56ae5a3cce481c94ba73cf4cd Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:07:31 -0700 Subject: [PATCH 07/11] SetMembershipInspector : Use fallbackValue to differentiate matches Rather than returning the PathMatcher::Result as the inspected value, we instead implement fallbackValue to allow us to return true from `value()` for an ExactMatch or true from `fallbackValue()` for an AncestorMatch. This aligns the SetMembershipInspector behaviour more closely with the AttributeInspector, where `fallbackValue()` returns inherited attribute values when no attribute exists at the location, and will help us to reduce the amount of special handling when dealing with each inspector in the future. We can also simplify the SetMembershipColumn tooltip handling a little here, as InspectorColumn will already be adding "Double-click to toggle" to the tooltip based on the BoolData now returned by the SetMembershipInspector. --- .../Private/SetMembershipInspector.h | 1 + .../SetMembershipInspectorTest.py | 18 +++++++- src/GafferSceneUI/SetMembershipInspector.cpp | 14 +++++- .../LightEditorBinding.cpp | 46 +++++++++---------- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/include/GafferSceneUI/Private/SetMembershipInspector.h b/include/GafferSceneUI/Private/SetMembershipInspector.h index 81f2c36f45b..3289b05c673 100644 --- a/include/GafferSceneUI/Private/SetMembershipInspector.h +++ b/include/GafferSceneUI/Private/SetMembershipInspector.h @@ -81,6 +81,7 @@ class GAFFERSCENEUI_API SetMembershipInspector : public Inspector /// those are found. Gaffer::ValuePlugPtr source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const override; EditFunctionOrFailure editFunction( Gaffer::EditScope *scope, const GafferScene::SceneAlgo::History *history ) const override; + IECore::ConstObjectPtr fallbackValue( const GafferScene::SceneAlgo::History *history ) const override; private : diff --git a/python/GafferSceneUITest/SetMembershipInspectorTest.py b/python/GafferSceneUITest/SetMembershipInspectorTest.py index a1080818a3e..4ff50b6ec23 100644 --- a/python/GafferSceneUITest/SetMembershipInspectorTest.py +++ b/python/GafferSceneUITest/SetMembershipInspectorTest.py @@ -110,7 +110,21 @@ def testValue( self ) : self.assertEqual( self.__inspect( plane["out"], "/plane", "planeSet" ).value().value, - IECore.PathMatcher.Result.ExactMatch + True + ) + + def testFallbackValue( self ) : + + plane = GafferScene.Plane() + group = GafferScene.Group() + group["sets"].setValue( "planeSet" ) + group["in"][0].setInput( plane["out"] ) + + inspection = self.__inspect( group["out"], "/group/plane", "planeSet" ) + self.assertEqual( inspection.value().value, True ) + self.assertEqual( + inspection.sourceType(), + GafferSceneUI.Private.Inspector.Result.SourceType.Fallback ) def testSourceAndEdits( self ) : @@ -346,7 +360,7 @@ def testObjectSourceFallback( self ) : self.__assertExpectedResult( self.__inspect( plane["out"], "/plane", "planeSet" ), source = plane["sets"], - sourceType = GafferSceneUI.Private.Inspector.Result.SourceType.Other, + sourceType = GafferSceneUI.Private.Inspector.Result.SourceType.Fallback, editable = True ) diff --git a/src/GafferSceneUI/SetMembershipInspector.cpp b/src/GafferSceneUI/SetMembershipInspector.cpp index 894fdf4bcc3..3116c95b931 100644 --- a/src/GafferSceneUI/SetMembershipInspector.cpp +++ b/src/GafferSceneUI/SetMembershipInspector.cpp @@ -211,7 +211,19 @@ IECore::ConstObjectPtr SetMembershipInspector::value( const GafferScene::SceneAl auto matchResult = (PathMatcher::Result)setMembers->readable().match( path ); - return new IntData( matchResult ); + // Return nullptr for non-exact match so `fallbackValue()` has the opportunity to provide + // an AncestorMatch. + return matchResult & IECore::PathMatcher::Result::ExactMatch ? new BoolData( true ) : nullptr; +} + +IECore::ConstObjectPtr SetMembershipInspector::fallbackValue( const GafferScene::SceneAlgo::History *history ) const +{ + const auto &path = history->context->get( ScenePlug::scenePathContextName ); + ConstPathMatcherDataPtr setMembers = history->scene->set( m_setName ); + + auto matchResult = (PathMatcher::Result)setMembers->readable().match( path ); + + return new BoolData( matchResult & IECore::PathMatcher::Result::AncestorMatch ); } Gaffer::ValuePlugPtr SetMembershipInspector::source( const GafferScene::SceneAlgo::History *history, std::string &editWarning ) const diff --git a/src/GafferSceneUIModule/LightEditorBinding.cpp b/src/GafferSceneUIModule/LightEditorBinding.cpp index 292bedb2b25..01c65e66acb 100644 --- a/src/GafferSceneUIModule/LightEditorBinding.cpp +++ b/src/GafferSceneUIModule/LightEditorBinding.cpp @@ -417,44 +417,44 @@ class SetMembershipColumn : public InspectorColumn return result; } - std::string toolTip; - if( auto toolTipData = runTimeCast( result.toolTip ) ) - { - toolTip = toolTipData->readable(); - } - - if( auto value = runTimeCast( result.value ) ) + if( auto value = runTimeCast( result.value ) ) { - if( value->readable() & PathMatcher::Result::ExactMatch ) - { - result.icon = m_setMemberIconData; - } - else if( value->readable() & PathMatcher::Result::AncestorMatch ) + if( value->readable() ) { - ConstPathMatcherDataPtr setMembersData = scenePath->getScene()->set( m_setName ); - const PathMatcher &setMembers = setMembersData->readable(); + ScenePlug::PathScope pathScope( scenePath->getContext(), &scenePath->names() ); + pathScope.setCanceller( canceller ); - ScenePlug::ScenePath currentPath( scenePath->names() ); - while( !currentPath.empty() ) + Inspector::ConstResultPtr inspectorResult = inspector()->inspect(); + if( inspectorResult->sourceType() != Inspector::Result::SourceType::Fallback ) + { + result.icon = m_setMemberIconData; + } + else { - if( setMembers.match( currentPath ) & PathMatcher::Result::ExactMatch ) + result.icon = m_setMemberIconFadedData; + + ConstPathMatcherDataPtr setMembersData = scenePath->getScene()->set( m_setName ); + const PathMatcher &setMembers = setMembersData->readable(); + + ScenePlug::ScenePath currentPath( scenePath->names() ); + while( !currentPath.empty() ) { - result.icon = m_setMemberIconFadedData; - toolTip = "Inherited from : " + ScenePlug::pathToString( currentPath ); - break; + if( setMembers.match( currentPath ) & PathMatcher::Result::ExactMatch ) + { + result.toolTip = new StringData( "Inherited from : " + ScenePlug::pathToString( currentPath ) + "\n\nDouble-click to toggle" ); + break; + } + currentPath.pop_back(); } - currentPath.pop_back(); } } } - if( !result.icon ) { result.icon = m_setMemberUndefinedIconData; } result.value = nullptr; - result.toolTip = new StringData( toolTip + ( toolTip.size() ? "\n\n" : "" ) + "Double-click to toggle" ); return result; } From 79aafbaa302620c8733a93dea5ff3a98180be9ad Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Thu, 11 Jul 2024 12:05:36 -0700 Subject: [PATCH 08/11] LightEditor : Improve display of fallback values By using the same dimmed foreground colour for fallback values as used by the RenderPassEditor. This does introduce a small amount of additional duplication with the RenderPassEditor's OptionInspectorColumn, but this will be fairly short lived as we'll be replacing both of these columns with a single common InspectorColumn for Gaffer 1.5. --- src/GafferSceneUIModule/LightEditorBinding.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GafferSceneUIModule/LightEditorBinding.cpp b/src/GafferSceneUIModule/LightEditorBinding.cpp index 01c65e66acb..f6275f1a306 100644 --- a/src/GafferSceneUIModule/LightEditorBinding.cpp +++ b/src/GafferSceneUIModule/LightEditorBinding.cpp @@ -177,6 +177,7 @@ const boost::container::flat_map g_sourceTypeColors = { (int)Inspector::Result::SourceType::Other, nullptr }, { (int)Inspector::Result::SourceType::Fallback, nullptr }, }; +const Color4fDataPtr g_fallbackValueForegroundColor = new Color4fData( Imath::Color4f( 163, 163, 163, 255 ) / 255.0f ); class InspectorColumn : public PathColumn { @@ -221,7 +222,12 @@ class InspectorColumn : public PathColumn result.icon = runTimeCast( inspectorResult->value() ); result.background = g_sourceTypeColors.at( (int)inspectorResult->sourceType() ); std::string toolTip; - if( auto source = inspectorResult->source() ) + if( inspectorResult->sourceType() == Inspector::Result::SourceType::Fallback ) + { + toolTip = "Source : Fallback value"; + result.foreground = g_fallbackValueForegroundColor; + } + else if( auto source = inspectorResult->source() ) { toolTip = "Source : " + source->relativeName( source->ancestor() ); } From da9d731a0eedd820dc8cdf783c523ff8245293b0 Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:09:53 -0700 Subject: [PATCH 09/11] LightEditor : Toggle based on inspected value Now that the AttributeInspector returns inherited attribute values as a fallback, and the SetMembershipInspector is returning True when the path is a set member or the descendant of a set member, we can remove this special handling and simply toggle based on the inspected values. --- python/GafferSceneUI/LightEditor.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/python/GafferSceneUI/LightEditor.py b/python/GafferSceneUI/LightEditor.py index d59dc072838..06442fbaa0e 100644 --- a/python/GafferSceneUI/LightEditor.py +++ b/python/GafferSceneUI/LightEditor.py @@ -458,26 +458,7 @@ def __toggleBoolean( self, inspectors, inspections ) : # First we need to find out what the new value would be for each plug in isolation. for inspector, pathInspections in inspectors.items() : for path, inspection in pathInspections.items() : - currentValue = inspection.value().value if inspection.value() is not None else None - - if isinstance( inspector, GafferSceneUI.Private.AttributeInspector ) : - fullAttributes = self.__settingsNode["in"].fullAttributes( path[:-1] ) - parentValueData = fullAttributes.get( inspector.name(), None ) - parentValue = parentValueData.value if parentValueData is not None else False - - currentValues.append( currentValue if currentValue is not None else parentValue ) - elif isinstance( inspector, GafferSceneUI.Private.SetMembershipInspector ) : - if currentValue is not None : - currentValues.append( - True if currentValue & ( - IECore.PathMatcher.Result.ExactMatch | - IECore.PathMatcher.Result.AncestorMatch - ) else False - ) - else : - currentValues.append( False ) - else : - currentValues.append( currentValue ) + currentValues.append( inspection.value().value if inspection.value() is not None else False ) # Now set the value for all plugs, defaulting to `True` if they are not # currently all the same. From 46786d59ad2fa019021c5cd6314853bc2cc4d928 Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Fri, 12 Jul 2024 13:22:23 -0700 Subject: [PATCH 10/11] HistoryWindow : Improve presentation of fallback values Improve the display of fallback values by replacing the SetMembershipInspector specific `_SetMembershipColumn` with a more generic `_ValueColumn` able to present fallback values provided by the inspectors via the new `history:fallbackValue` HistoryPath property. In the process we lose the "green dot" icon presentation of set membership, but we're already not presenting `light:mute` attributes with their "red dot" icon so this seems a reasonable tradeoff. There's a future where we may be able to start configuring this with metadata specifying icons. There are further improvements that could be made to the HistoryPath itself, such as including entries when values change so it'd be possible to view changes to inherited attributes as part of the history, and making it more apparent that a plug in the history belongs to a disabled node, though these are left to be tackled at another time... --- Changes.md | 1 + include/GafferSceneUI/Private/Inspector.h | 4 +- python/GafferSceneUI/_HistoryWindow.py | 26 ++++--- python/GafferSceneUITest/HistoryPathTest.py | 77 +++++++++++++++++++++ src/GafferSceneUI/Inspector.cpp | 7 ++ 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/Changes.md b/Changes.md index a8dfcbc5e20..3ca8fb7b1a5 100644 --- a/Changes.md +++ b/Changes.md @@ -5,6 +5,7 @@ Improvements ------------ - LightEditor : Values of inherited attributes are now displayed in the Light Editor. These are presented as dimmed "fallback" values. +- LightEditor, RenderPassEditor : Fallback values shown in the history window are displayed with the same dimmed text colour used for fallback values in editor columns. 1.4.9.0 (relative to 1.4.8.0) ======= diff --git a/include/GafferSceneUI/Private/Inspector.h b/include/GafferSceneUI/Private/Inspector.h index c0c1d8e46e4..372e3186d7e 100644 --- a/include/GafferSceneUI/Private/Inspector.h +++ b/include/GafferSceneUI/Private/Inspector.h @@ -126,8 +126,8 @@ class GAFFERSCENEUI_API Inspector : public IECore::RefCounted, public Gaffer::Si /// Returns a `Path` representing the history for the inspected property /// in the current context. The path has a child for each predecessor in - /// the history, and properties `history:value`, `history:operation`, - /// `history:source`, `history:editWarning` and `history:node`. + /// the history, and properties `history:value`, `history:fallbackValue`, + /// `history:operation`, `history:source`, `history:editWarning` and `history:node`. Gaffer::PathPtr historyPath(); protected : diff --git a/python/GafferSceneUI/_HistoryWindow.py b/python/GafferSceneUI/_HistoryWindow.py index efc5ee31385..498994dbe45 100644 --- a/python/GafferSceneUI/_HistoryWindow.py +++ b/python/GafferSceneUI/_HistoryWindow.py @@ -34,7 +34,7 @@ # ########################################################################## -import IECore +import imath import Gaffer import GafferUI @@ -98,28 +98,34 @@ def headerData( self, canceller = None ) : return self.CellData( self.__title ) -# \todo This duplicates logic from (in this case) `_GafferSceneUI._LightEditorSetMembershipColumn`. +# \todo This duplicates logic from (in this case) `_GafferSceneUI._LightEditorInspectorColumn`. # Refactor to allow calling `_GafferSceneUI.InspectorColumn.cellData()` from `_HistoryWindow` to # remove this duplication for columns that customize their value presentation. -class _SetMembershipColumn( GafferUI.PathColumn ) : +class _ValueColumn( GafferUI.PathColumn ) : - def __init__( self, title, property ) : + def __init__( self, title, property, fallbackProperty ) : GafferUI.PathColumn.__init__( self ) self.__title = title self.__property = property + self.__fallbackProperty = fallbackProperty def cellData( self, path, canceller = None ) : cellValue = path.property( self.__property ) + fallbackValue = path.property( self.__fallbackProperty ) data = self.CellData() - if cellValue & IECore.PathMatcher.Result.ExactMatch : - data.icon = "setMember.png" - elif cellValue & IECore.PathMatcher.Result.AncestorMatch : - data.icon = "setMemberFaded.png" + if cellValue is not None : + data.value = cellValue + elif fallbackValue is not None : + data.value = fallbackValue + data.foreground = imath.Color4f( 0.64, 0.64, 0.64, 1.0 ) + + if isinstance( data.value, ( imath.Color3f, imath.Color4f ) ) : + data.icon = data.value return data @@ -147,9 +153,7 @@ def __init__( self, inspector, scenePath, context, scriptNode, title=None, **kw Gaffer.DictPath( {}, "/" ), columns = ( _NodeNameColumn( "Node", "history:node", self.__scriptNode ), - _SetMembershipColumn( "Value", "history:value" ) if ( - isinstance( inspector, GafferSceneUI.Private.SetMembershipInspector ) - ) else GafferUI.PathListingWidget.StandardColumn( "Value", "history:value" ), + _ValueColumn( "Value", "history:value", "history:fallbackValue" ), _OperationIconColumn( "Operation", "history:operation" ), ), sortable = False, diff --git a/python/GafferSceneUITest/HistoryPathTest.py b/python/GafferSceneUITest/HistoryPathTest.py index b620dd775eb..e7a55d1de1b 100644 --- a/python/GafferSceneUITest/HistoryPathTest.py +++ b/python/GafferSceneUITest/HistoryPathTest.py @@ -201,6 +201,7 @@ def testPropertyNames( self ) : "fullName", "name", "history:value", + "history:fallbackValue", "history:operation", "history:source", "history:editWarning", @@ -247,6 +248,7 @@ def testProperties( self ) : self.assertEqual( c[0].property( "name" ), str( c[0][-1] ) ) self.assertEqual( c[0].property( "history:node" ), s["testLight"] ) self.assertEqual( c[0].property( "history:value" ), 0.0 ) + self.assertEqual( c[0].property( "history:fallbackValue" ), None ) self.assertEqual( c[0].property( "history:operation" ), Gaffer.TweakPlug.Mode.Create ) self.assertEqual( c[0].property( "history:source" ), s["testLight"]["parameters"]["exposure"] ) self.assertEqual( c[0].property( "history:editWarning" ), "" ) @@ -254,6 +256,7 @@ def testProperties( self ) : self.assertEqual( c[1].property( "name" ), str( c[1][-1] ) ) self.assertEqual( c[1].property( "history:node" ), s["tweaks"] ) self.assertEqual( c[1].property( "history:value" ), 2.0 ) + self.assertEqual( c[1].property( "history:fallbackValue" ), None ) self.assertEqual( c[1].property( "history:operation" ), Gaffer.TweakPlug.Mode.Add ) self.assertEqual( c[1].property( "history:source" ), exposureTweak ) self.assertEqual( c[1].property( "history:editWarning" ), "" ) @@ -261,6 +264,7 @@ def testProperties( self ) : self.assertEqual( c[2].property( "name" ), str( c[2][-1] ) ) self.assertEqual( c[2].property( "history:node" ), s["editScope"]["LightEdits"]["ShaderTweaks"] ) self.assertEqual( c[2].property( "history:value" ), 3.0 ) + self.assertEqual( c[2].property( "history:fallbackValue" ), None ) self.assertEqual( c[2].property( "history:operation" ), Gaffer.TweakPlug.Mode.Replace ) self.assertEqual( c[2].property( "history:source" ), edit ) self.assertEqual( c[2].property( "history:editWarning" ), "" ) @@ -354,6 +358,79 @@ def testEmptyHistory( self ) : self.assertEqual( len( historyPath.children() ), 0 ) + def testAttributeFallbackValues( self ) : + + s = Gaffer.ScriptNode() + + s["testLight"] = GafferSceneTest.TestLight() + + s["group"] = GafferScene.Group() + s["group"]["in"][0].setInput( s["testLight"]["out"] ) + + s["groupFilter"] = GafferScene.PathFilter() + s["groupFilter"]["paths"].setValue( IECore.StringVectorData( [ "/group" ] ) ) + + s["tweaks"] = GafferScene.AttributeTweaks() + s["tweaks"]["in"].setInput( s["group"]["out"] ) + s["tweaks"]["filter"].setInput( s["groupFilter"]["out"] ) + + groupTextureResolutionTweak = Gaffer.TweakPlug( "gl:visualiser:maxTextureResolution", 1024 ) + groupTextureResolutionTweak["mode"].setValue( Gaffer.TweakPlug.Mode.Create ) + s["tweaks"]["tweaks"].addChild( groupTextureResolutionTweak ) + + s["lightFilter"] = GafferScene.PathFilter() + s["lightFilter"]["paths"].setValue( IECore.StringVectorData( [ "/group/light" ] ) ) + + s["openGLAttributes"] = GafferScene.OpenGLAttributes() + s["openGLAttributes"]["in"].setInput( s["tweaks"]["out"] ) + s["openGLAttributes"]["filter"].setInput( s["lightFilter"]["out"] ) + s["openGLAttributes"]["attributes"]["visualiserMaxTextureResolution"]["enabled"].setValue( True ) + s["openGLAttributes"]["attributes"]["visualiserMaxTextureResolution"]["value"].setValue( 1536 ) + + inspector = GafferSceneUI.Private.AttributeInspector( + s["openGLAttributes"]["out"], None, "gl:visualiser:maxTextureResolution", + ) + + with Gaffer.Context() as context : + context["scene:path"] = IECore.InternedStringVectorData( [ "group", "light" ] ) + historyPath = inspector.historyPath() + + c = historyPath.children() + + self.assertEqual( c[0].property( "name" ), str( c[0][-1] ) ) + self.assertEqual( c[0].property( "history:node" ), s["openGLAttributes"] ) + self.assertEqual( c[0].property( "history:value" ), 1536 ) + self.assertEqual( c[0].property( "history:fallbackValue" ), 1536 ) + self.assertEqual( c[0].property( "history:operation" ), Gaffer.TweakPlug.Mode.Create ) + self.assertEqual( c[0].property( "history:source" ), s["openGLAttributes"]["attributes"]["visualiserMaxTextureResolution"] ) + self.assertEqual( c[0].property( "history:editWarning" ), "Edits to \"gl:visualiser:maxTextureResolution\" may affect other locations in the scene." ) + + s["openGLAttributes"]["enabled"].setValue( False ) + + with Gaffer.Context() as context : + context["scene:path"] = IECore.InternedStringVectorData( [ "group", "light" ] ) + historyPath = inspector.historyPath() + + c = historyPath.children() + + self.assertEqual( c[0].property( "name" ), str( c[0][-1] ) ) + self.assertEqual( c[0].property( "history:node" ), s["testLight"] ) + self.assertEqual( c[0].property( "history:value" ), None ) + self.assertEqual( c[0].property( "history:fallbackValue" ), None ) + self.assertEqual( c[0].property( "history:operation" ), Gaffer.TweakPlug.Mode.Create ) + self.assertEqual( c[0].property( "history:source" ), s["testLight"]["visualiserAttributes"]["maxTextureResolution"] ) + self.assertEqual( c[0].property( "history:editWarning" ), "" ) + + # Disabling the openGLAttributes node results in its `visualiserMaxTextureResolution` plug remaining + # in the history but providing no value. We'll be able to see the value set on /group as the fallback + # value. + self.assertEqual( c[1].property( "name" ), str( c[1][-1] ) ) + self.assertEqual( c[1].property( "history:node" ), s["openGLAttributes"] ) + self.assertEqual( c[1].property( "history:value" ), None ) + self.assertEqual( c[1].property( "history:fallbackValue" ), 1024 ) + self.assertEqual( c[1].property( "history:operation" ), Gaffer.TweakPlug.Mode.Create ) + self.assertEqual( c[1].property( "history:source" ), s["openGLAttributes"]["attributes"]["visualiserMaxTextureResolution"] ) + self.assertEqual( c[1].property( "history:editWarning" ), "Edits to \"gl:visualiser:maxTextureResolution\" may affect other locations in the scene." ) if __name__ == "__main__": unittest.main() diff --git a/src/GafferSceneUI/Inspector.cpp b/src/GafferSceneUI/Inspector.cpp index 4de806889d1..1ba1531e28f 100644 --- a/src/GafferSceneUI/Inspector.cpp +++ b/src/GafferSceneUI/Inspector.cpp @@ -56,6 +56,7 @@ using namespace GafferSceneUI::Private; using ConstPredecessors = std::vector; static InternedString g_valuePropertyName( "history:value" ); +static InternedString g_fallbackValuePropertyName( "history:fallbackValue" ); static InternedString g_operationPropertyName( "history:operation" ); static InternedString g_sourcePropertyName( "history:source" ); static InternedString g_editWarningPropertyName( "history:editWarning" ); @@ -441,6 +442,7 @@ void Inspector::HistoryPath::propertyNames( std::vector &names, if( isLeaf() ) { names.push_back( g_valuePropertyName ); + names.push_back( g_fallbackValuePropertyName ); names.push_back( g_operationPropertyName); names.push_back( g_sourcePropertyName ); names.push_back( g_editWarningPropertyName ); @@ -466,6 +468,7 @@ ConstRunTimeTypedPtr Inspector::HistoryPath::property( const InternedString &nam if( isLeaf() && ( name == g_valuePropertyName || + name == g_fallbackValuePropertyName || name == g_operationPropertyName || name == g_sourcePropertyName || name == g_editWarningPropertyName @@ -486,6 +489,10 @@ ConstRunTimeTypedPtr Inspector::HistoryPath::property( const InternedString &nam { return runTimeCast( m_inspector->value( it->history.get() ) ); } + else if( name == g_fallbackValuePropertyName ) + { + return runTimeCast( m_inspector->fallbackValue( it->history.get() ) ); + } else if( name == g_operationPropertyName ) { if( auto tweakPlug = runTimeCast( source ) ) From ce53e1f376234297c2a449343a48de8a7f4dc56d Mon Sep 17 00:00:00 2001 From: Murray Stevenson <50844517+murraystevenson@users.noreply.github.com> Date: Thu, 18 Jul 2024 12:47:14 -0700 Subject: [PATCH 11/11] LightEditor : Add todos --- src/GafferSceneUIModule/LightEditorBinding.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/GafferSceneUIModule/LightEditorBinding.cpp b/src/GafferSceneUIModule/LightEditorBinding.cpp index f6275f1a306..fcb077c8044 100644 --- a/src/GafferSceneUIModule/LightEditorBinding.cpp +++ b/src/GafferSceneUIModule/LightEditorBinding.cpp @@ -274,6 +274,8 @@ class InspectorColumn : public PathColumn }; +/// \todo `MuteColumn` and `SetMembershipColumn` should not exist and we intend +/// to continue refactoring until it is possible to remove them entirely. class MuteColumn : public InspectorColumn { @@ -340,6 +342,8 @@ class MuteColumn : public InspectorColumn } result.value = nullptr; + /// \todo Remove this once AttributeInspector can provide a default value when + /// no attribute exists, then InspectorColumn would always provide the toggle tooltip. if( auto toolTipData = runTimeCast( result.toolTip ) ) { std::string toolTip = toolTipData->readable();