Skip to content

Commit 6a4e52b

Browse files
EMSUSD-3340 support drag-and-drop material in AE
New drag-and-drop text field: - Added `DragAndDropTextField` class to make a MEL textField support drag-and-drop of text. - The class supports validation of the dropped value. - The class has callbacks for user text changes and drag-and-drop changes. Enhance AE material UI: - Use the drag-and-drop text filed in the material custom control. - Added `AssignedMaterialUI` to replace `TextField` for clarity. - Added undo label to metadata UI. - Added goto-prim button for all fields with prim paths. - Don't create the graph buttons when there is no LookdevX. Edit restrictions for material commands: - Added edit restriction enforcement in the bind, unbind and strength commands. - Made the unbind command not unbind collections. (We're not managing collections.) - Added unit tests for restrictions. - Edit-routing for the material binding commands as property routing for the property "material:binding". - We don't distinguish benteen purpose and strength for routing to avoid having too many different routings.
1 parent 03e5aa8 commit 6a4e52b

7 files changed

Lines changed: 328 additions & 74 deletions

File tree

lib/mayaUsd/resources/ae/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ foreach(_SUBDIR ${MAYAUSD_AE_TEMPLATES})
3434
${_SUBDIR}/metadataCustomControl.py
3535
${_SUBDIR}/relationshipCustomControl.py
3636
${_SUBDIR}/observers.py
37+
${_SUBDIR}/dragAndDropTextField.py
3738
DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/python/ufe_ae/usd/nodes/${_SUBDIR}
3839
)
3940

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Copyright 2026 Autodesk
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
import maya.cmds as cmds
17+
18+
import mayaUsdUtils
19+
20+
21+
class DragAndDropTextField:
22+
'''
23+
Class to hold the UI elements for a text field supporting drag-and-drop.
24+
'''
25+
def __init__(self, uiTooltip):
26+
self.field = cmds.textField(annotation=uiTooltip, editable=False, enableKeyboardFocus=True)
27+
self.lastValue = ''
28+
29+
def fillUI(self, text, placeholder=None, annotation=None, editable=True):
30+
'''
31+
Fill the text field with the given text, optional placeholder and optional annotation.
32+
'''
33+
try:
34+
self.lastValue = text
35+
args = {
36+
'editable': editable,
37+
'text': text
38+
}
39+
if placeholder:
40+
args['placeholderText'] = placeholder
41+
if annotation:
42+
args['annotation'] = annotation
43+
cmds.textField(self.field, edit=True, **args)
44+
except Exception as e:
45+
cmds.warning(f'Error filling text field values: {e}', noContext=True)
46+
47+
def setChangeCallback(self, callback, undoLabel):
48+
'''
49+
Set the callback to be called when the text field value is modified by user and confirmed
50+
by either pressing Enter or losing focus. The callback receives the new value.
51+
'''
52+
@mayaUsdUtils.setUndoLabel(undoLabel)
53+
def changeCallback(value, *args, **kwargs):
54+
try:
55+
callback(value)
56+
except Exception as e:
57+
cmds.warning(f'Error in text field change callback: {e}', noContext=True)
58+
try:
59+
cmds.textField(self.field, edit=True, changeCommand=changeCallback)
60+
except Exception as e:
61+
cmds.warning(f'Error connecting text field change callback: {e}', noContext=True)
62+
63+
def _validateDroppedValue(self, value, validation):
64+
'''
65+
Try to detect drag-and-drop.
66+
67+
In Maya, when text is dropped, it is inserted in the middle of the existing text.
68+
What we want is replacement, not insertion. So we try to detect that and extract
69+
the dropped value from the new text. Unfortunately, there may be corner cases
70+
where we cannot be sure what is the new text. For example, if the previous text
71+
can be found multiple times in the new text, we cannot be sure which one was replaced.
72+
We extract all possible new values and let the validation function decide which one is valid.
73+
'''
74+
# If the last value is the same as the new value, it is not a drop.
75+
if self.lastValue == value:
76+
return None
77+
# If the new value is shorter than the last value, it is not a drop.
78+
# If the new value is just one character longer, then we assume the user is typing and not dropping.
79+
if len(value) <= len(self.lastValue) + 1:
80+
return None
81+
82+
newValues = []
83+
lenToExtract = len(value) - len(self.lastValue)
84+
for insertionPoint in range(len(value) - lenToExtract + 1):
85+
potentialOldValue = value[:insertionPoint] + value[insertionPoint + lenToExtract:]
86+
if potentialOldValue == self.lastValue:
87+
newValues.append(value[insertionPoint:insertionPoint + lenToExtract])
88+
89+
potentialNewValue = validation(self.lastValue, newValues)
90+
if potentialNewValue is not None:
91+
return potentialNewValue
92+
93+
return value
94+
95+
def setImmediateChangeCallback(self, validation, callback, undoLabel):
96+
'''
97+
Set callback for immediate text field value change, such as when the user is typing or dropping text.
98+
The validation function is called to validate the new value and determine if it should be accepted.
99+
If the validation function returns a new value (i.e not None), it will be used; otherwise, the original
100+
new value will be used. Then the callback is called with the new value and a boolean indicating if it
101+
was a dropped value.
102+
103+
The validation function receives the last value and a list of potential new values extracted from the
104+
current text field value.
105+
106+
The callback function receives the new value and a boolean indicating if it was a dropped value.
107+
'''
108+
@mayaUsdUtils.setUndoLabel(undoLabel)
109+
def validationCallback(value, *args, **kwargs):
110+
try:
111+
droppedValue = self._validateDroppedValue(value, validation)
112+
newValue = droppedValue if droppedValue is not None else value
113+
self.lastValue = newValue
114+
callback(newValue, bool(droppedValue is not None))
115+
except Exception as e:
116+
cmds.warning(f'Error in text field validation: {e}', noContext=True)
117+
118+
try:
119+
cmds.textField(self.field, edit=True, textChangedCommand=validationCallback)
120+
except Exception as e:
121+
cmds.warning(f'Error connecting text field change callback: {e}', noContext=True)

lib/mayaUsd/resources/ae/usdschemabase/materialCustomControl.py

Lines changed: 78 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import maya.common.ui as mui
3030

3131
from mayaUsdLibRegisterStrings import getMayaUsdLibString
32+
from .dragAndDropTextField import DragAndDropTextField
3233

3334
@dataclass(slots=True)
3435
class MaterialPurposeUI:
@@ -51,7 +52,7 @@ class MaterialCustomControl(object):
5152
getMayaUsdLibString('kLabelStrongerMaterial') : 'strongerThanDescendants',
5253
}
5354

54-
TextField = collections.namedtuple('TextField', ['layout', 'field', 'button', 'graphMenu'])
55+
TextField = collections.namedtuple('TextField', ['layout', 'field', 'gotoButton', 'graphButton','graphMenu'])
5556

5657
@staticmethod
5758
def hasMaterial(prim):
@@ -100,6 +101,7 @@ def onCreate(self, *args):
100101
for purpose in [UsdShade.Tokens.allPurpose, UsdShade.Tokens.preview, UsdShade.Tokens.full]:
101102
textField = self.materialPurposeUIs[purpose].material.field
102103
self._connectTextFieldChangeCallback(purpose, textField)
104+
self._connectTextFieldImmediateChangeCallback(purpose, textField)
103105

104106
# Fill the UI.
105107
self.refresh()
@@ -131,84 +133,94 @@ def _createMaterialUI(self, purpose):
131133
'''
132134
purposeName = self._getPurposeForLabels(purpose)
133135

134-
# Note: icon image taken from LookdevX plugin.
135-
hasLookdevX = self._hasLookdevX()
136-
graphIcon = 'LookdevX.png' if hasLookdevX else None
137-
138136
purposeUI = self.materialPurposeUIs[purpose]
139-
purposeUI.material = self._createTextField(
140-
'material',
141-
f'kLabel{purposeName}Material',
142-
f'kAnn{purposeName}Material',
143-
graphIcon,
144-
'kAnnShowMaterialInLookdevx', True)
137+
purposeUI.material = self._createTextField('material', f'kLabel{purposeName}Material', f'kAnn{purposeName}Material', canGraph=True)
145138

146139
def _createInheritedUI(self, purpose):
147140
'''
148141
Create the UI for a given material purpose.
149142
'''
150143
purposeName = self._getPurposeForLabels(purpose)
151144

152-
# Note: icon image taken from LookdevX plugin.
153-
hasLookdevX = self._hasLookdevX()
154-
graphIcon = 'LookdevX.png' if hasLookdevX else None
155-
156145
purposeUI = self.materialPurposeUIs[purpose]
157-
purposeUI.inherited = self._createTextField('inherited', f'kLabel{purposeName}InheritedMaterial', image=graphIcon, imageTooltipRes='kAnnShowMaterialInLookdevx', canGraph=True)
146+
purposeUI.inherited = self._createTextField('inherited', f'kLabel{purposeName}InheritedMaterial', canGraph=True)
158147
# Note: inArrow.png icon image taken from Maya resources.
159-
purposeUI.fromPrim = self._createTextField('from prim', f'kLabel{purposeName}InheritedFromPrim', image='inArrow.png')
148+
purposeUI.fromPrim = self._createTextField('from prim', f'kLabel{purposeName}InheritedFromPrim')
160149

161-
def _createTextField(self, longName, uiNameRes, uiTooltipRes=None, image=None, imageTooltipRes=None, canGraph=False):
150+
def _createTextField(self, longName, uiLabelRes, uiTooltipRes=None, canGraph=False):
162151
'''
163152
Create a disabled text field group and an optional image button with the correct label.
164153
'''
165-
uiLabel = getMayaUsdLibString(uiNameRes) if self.useNiceName else longName
154+
uiLabel = getMayaUsdLibString(uiLabelRes) if self.useNiceName else longName
166155
uiTooltip = getMayaUsdLibString(uiTooltipRes) if uiTooltipRes else uiLabel
167-
rowLayout = cmds.rowLayout(numberOfColumns=3, adjustableColumn3=2)
156+
rowLayout = cmds.rowLayout(numberOfColumns=4, adjustableColumn4=2)
168157
with mui.LayoutManager(rowLayout):
169158
cmds.text(label=uiLabel, annotation=uiTooltip)
170-
textField = cmds.textField(annotation=uiTooltip, editable=False, enableKeyboardFocus=True)
171-
if image:
172-
imageTooltip = getMayaUsdLibString(imageTooltipRes) if imageTooltipRes else ''
173-
button = cmds.symbolButton(enable=False, image=image, annotation=imageTooltip)
159+
textField = DragAndDropTextField(uiTooltip)
160+
gotoButton = cmds.symbolButton(enable=False, image='inArrow.png')
161+
162+
hasLookdevX = self._hasLookdevX()
163+
if canGraph and hasLookdevX:
164+
# Note: icon image taken from LookdevX plugin.
165+
graphIcon = 'LookdevX.png' if hasLookdevX else None
166+
167+
graphTooltip = getMayaUsdLibString('kAnnShowMaterialInLookdevx')
168+
graphButton = cmds.symbolButton(enable=False, image=graphIcon, annotation=graphTooltip)
169+
graphMenu = self._createGraphMenu(graphButton)
174170
else:
175-
button = None
171+
graphButton = None
172+
graphMenu = None
176173

177-
if canGraph:
178-
graphMenu = self._createGraphMenu(button)
179-
else:
180-
graphMenu = None
174+
return MaterialCustomControl.TextField(rowLayout, textField, gotoButton, graphButton, graphMenu)
181175

182-
return MaterialCustomControl.TextField(rowLayout, textField, button, graphMenu)
176+
def _setMaterialPurposeBinding(self, purpose, value):
177+
try:
178+
ufePath = ufe.PathString.string(self.item.path())
179+
if value:
180+
cmd = mayaUsd.ufe.BindMaterialCommand(ufePath, value, purpose)
181+
else:
182+
cmd = mayaUsd.ufe.UnbindMaterialCommand(ufePath, purpose)
183+
ufeCmdWrapper.execute(cmd)
184+
except Exception as e:
185+
cmds.warning(f'Error executing material command: {e}', noContext=True)
186+
self.refresh()
183187

184188
def _connectTextFieldChangeCallback(self, purpose, textField):
189+
def callback(value):
190+
self._setMaterialPurposeBinding(purpose, value)
185191

186-
@mayaUsdUtils.setUndoLabel(getMayaUsdLibString('kLabelSetMaterialBindingUndo'))
187-
def callback(value, *args, **kwargs):
192+
textField.setChangeCallback(callback, getMayaUsdLibString('kLabelSetMaterialBindingUndo'))
193+
194+
def _connectTextFieldImmediateChangeCallback(self, purpose, textField):
195+
def callback(newValue, wasDropped):
196+
if not wasDropped:
197+
return
198+
self._setMaterialPurposeBinding(purpose, newValue)
199+
200+
def validation(lastValue, newValues):
188201
try:
189-
ufePath = ufe.PathString.string(self.item.path())
190-
if value:
191-
cmd = mayaUsd.ufe.BindMaterialCommand(ufePath, value, purpose)
192-
else:
193-
cmd = mayaUsd.ufe.UnbindMaterialCommand(ufePath, purpose)
194-
ufeCmdWrapper.execute(cmd)
202+
for newValue in newValues:
203+
if not newValue:
204+
continue
205+
if newValue[0] not in ['|', '/']:
206+
continue
207+
newValue = newValue.split(',')[-1]
208+
# TODO: validate that it is a material?
209+
return newValue
195210
except Exception as e:
196-
print(f'Error executing material command: {e}')
197-
self.refresh()
211+
cmds.warning(f'Error validating material path: {e}', noContext=True)
212+
return None
198213

199-
try:
200-
cmds.textField(textField, edit=True, changeCommand=callback)
201-
except Exception as e:
202-
print(f'Error connecting text field change callback: {e}')
214+
textField.setImmediateChangeCallback(validation, callback, getMayaUsdLibString('kLabelSetMaterialBindingUndo'))
203215

204-
def _createGraphMenu(self, button):
216+
def _createGraphMenu(self, graphButton):
205217
'''
206218
Create a popup menu attached to the given button to graph a material.
207219
'''
208-
if not button:
220+
if not graphButton:
209221
return None
210222

211-
return cmds.popupMenu(parent=button, button=True)
223+
return cmds.popupMenu(parent=graphButton, button=True)
212224

213225
def _createDropDownField(self, longName, uiNameRes, elementsRes):
214226
'''
@@ -229,7 +241,7 @@ def callback(value, *args, **kwargs):
229241
# Force update of all children in the VP2 delegate.
230242
cmds.evalDeferred(lambda: ufe.Scene.notify(ufe.ObjectRename(self.item, self.item.path())))
231243
except Exception as e:
232-
print(f'Error executing material command: {e}')
244+
cmds.warning(f'Error executing material command: {e.args[0] if e.args else e}', noContext=True)
233245
self.refresh()
234246

235247
uiLabel = getMayaUsdLibString(uiNameRes) if self.useNiceName else longName
@@ -328,27 +340,30 @@ def _fillUIForPurpose(self, purpose, mat, matRel, directMat):
328340
cmds.rowLayout(purposeUI.inherited.layout, edit=True, visible=False)
329341
cmds.rowLayout(purposeUI.fromPrim.layout, edit=True, visible=False)
330342

331-
self._fillGraphButton(text, purposeUI.material.button, purposeUI.material.graphMenu)
332-
self._fillGraphButton(inherited, purposeUI.inherited.button, purposeUI.inherited.graphMenu)
333-
self._fillGotoPrimButton(purposeUI, fromPathStr)
343+
self._fillGraphButton(text, purposeUI.material.graphButton, purposeUI.material.graphMenu)
344+
self._fillGraphButton(inherited, purposeUI.inherited.graphButton, purposeUI.inherited.graphMenu)
345+
346+
self._fillGotoPrimButton(purposeUI.material.gotoButton, text)
347+
self._fillGotoPrimButton(purposeUI.inherited.gotoButton, inherited)
348+
self._fillGotoPrimButton(purposeUI.fromPrim.gotoButton, fromPathStr)
334349

335-
cmds.textField(purposeUI.material.field, edit=True, editable=True, text=text, placeholderText=placeholder, annotation=annotation)
336-
cmds.textField(purposeUI.inherited.field, edit=True, text=inherited)
337-
cmds.textField(purposeUI.fromPrim.field, edit=True, text=fromPathStr)
350+
purposeUI.material.field.fillUI(text, placeholder, annotation)
351+
purposeUI.inherited.field.fillUI(inherited, editable=False)
352+
purposeUI.fromPrim.field.fillUI(fromPathStr, editable=False)
338353

339-
def _fillGraphButton(self, matPathStr, button, menu):
354+
def _fillGraphButton(self, matPathStr, graphButton, menu):
340355
'''
341356
Fill the graph button with the correct command.
342357
'''
343358
# Note: only show the graph button if LookdevX was loaded when the UI
344359
# was created.
345-
if not button:
360+
if not graphButton:
346361
return
347362

348363
# Note: only show the graph button if LookdevX is currently loaded.
349364
hasLookdevX = self._hasLookdevX()
350365
canGraph = bool(matPathStr and hasLookdevX)
351-
cmds.symbolButton(button, edit=True, enable=canGraph, visible=hasLookdevX)
366+
cmds.symbolButton(graphButton, edit=True, enable=canGraph, visible=hasLookdevX)
352367

353368
if canGraph:
354369
ufePathStr = self._createUFEPathFromUSDPath(matPathStr)
@@ -414,20 +429,20 @@ def _showInExistingTab(ignore, tabName, ufePathStr):
414429
return
415430
cmds.lookdevXGraph(tabName=tabName, graphObject=ufePathStr)
416431

417-
def _fillGotoPrimButton(self, purposeUI, fromPath):
432+
def _fillGotoPrimButton(self, gotoButton, gotoUsdPath):
418433
'''
419434
Fill the goto-prim button with the correct command.
420435
'''
421-
showButton = bool(fromPath)
422-
cmds.symbolButton(purposeUI.fromPrim.button, edit=True, enable=showButton, visible=showButton)
436+
showButton = bool(gotoUsdPath)
437+
cmds.symbolButton(gotoButton, edit=True, enable=showButton, visible=showButton)
423438

424-
if fromPath:
425-
ufePathStr = self._createUFEPathFromUSDPath(fromPath)
439+
if gotoUsdPath:
440+
ufePathStr = self._createUFEPathFromUSDPath(gotoUsdPath)
426441
melCommand = 'updateAE "%s"' % ufePathStr
427442
command = lambda *_: mel.eval(melCommand)
428443
else:
429444
command = ''
430-
cmds.symbolButton(purposeUI.fromPrim.button, edit=True, command=command)
445+
cmds.symbolButton(gotoButton, edit=True, command=command)
431446

432447
def _createUFEPathFromUSDPath(self, usdPath):
433448
'''

0 commit comments

Comments
 (0)