SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: make the bulk node color operations one undo each
updateNodeColors() and setNodeColors() both looped setColor with nothing
holding the writes together, so undoing a scene-wide recolor cost one
ctrl-Z per node.
updateNodeColors() now decides first and writes second. Collecting the
work up front is what lets the group stay closed on a scene that needs
none -- an automatic pass runs on every hip load, and it should not leave
an empty entry on the undo stack for the file you just opened.
tools/check.py drives it through hou.undos rather than asserting the
shape of the code: three nodes recolor into one entry, a no-op pass adds
none, and a single undo reverts all three.
Co-Authored-By: Claude Opus 5 <[email protected]>
...op_lsgalante.developer_vector_migrate.1.1.hdalc | Bin 14167 -> 14177 bytes
python3.13libs/hc/hcnetworkeditor.py | 13 +++--
python3.13libs/hc/hcsession.py | 53 +++++++++++++--------
tools/check.py | 34 +++++++++++++
4 files changed, 76 insertions(+), 24 deletions(-)
diff --git a/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc b/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc
index 412d748..a8cc616 100644
Binary files a/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc and b/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc differ
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index fbab051..0be4917 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1239,11 +1239,14 @@ class HCNetworkEditor(HCPathTab):
color = hou.ui.selectColor(HCSettings().nodeColor())
if color is None: # dialog cancelled
return
- for node in nodes:
- node.setColor(color)
- # An explicit choice opts the node out of updateNodeColors(), which
- # would otherwise revert it to the default on the next hip load.
- node.destroyUserData("hc_custom_color", must_exist=False)
+ # One undo for the whole selection, not one per node.
+ with hou.undos.group("Set Node Colors"):
+ for node in nodes:
+ node.setColor(color)
+ # An explicit choice opts the node out of updateNodeColors(),
+ # which would otherwise revert it to the default on the next
+ # hip load.
+ node.destroyUserData("hc_custom_color", must_exist=False)
@command("Reset Node Colors")
def resetNodeColors(self):
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index d024f5b..fd3c36e 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -503,7 +503,12 @@ class HCSession:
new_color = settings.nodeColor()
new_hex = settings.nodeColorHex()
- count = 0
+ # Decide first, write second. Writing nothing when nothing changed is
+ # the point -- every setColor marks the hip modified, so an
+ # unconditional pass left a scene you had only opened asking to be
+ # saved -- and collecting the work first means the undo group below is
+ # never opened on a scene that needs none.
+ pending = []
# Nodes inside a locked HDA cannot be colored anyway. Skipping them at
# the traversal rather than per node means the walk never expands an
# asset's contents -- this runs on every hip load.
@@ -513,26 +518,36 @@ class HCSession:
continue
try:
current = node.color()
- recorded = parseHex(tag)
- if recorded is None:
- # Legacy "1" tags predate recording the color. Adopt them
- # once -- this load still overwrites whatever they carry --
- # and they follow the rule above from then on.
- if tag != "1":
- continue
- elif not colorsMatch(current, hou.Color(recorded)):
- continue # colored by hand since HC last wrote it
-
- # Write nothing when nothing changes. Every setColor marks the
- # hip modified, so an unconditional pass here left a scene you
- # had only opened asking to be saved.
- if not colorsMatch(current, new_color):
- node.setColor(new_color)
- count += 1
- if tag != new_hex:
- node.setUserData("hc_custom_color", new_hex)
except hou.Error:
continue
+ recorded = parseHex(tag)
+ if recorded is None:
+ # Legacy "1" tags predate recording the color. Adopt them once
+ # -- this load still overwrites whatever they carry -- and they
+ # follow the rule above from then on.
+ if tag != "1":
+ continue
+ elif not colorsMatch(current, hou.Color(recorded)):
+ continue # colored by hand since HC last wrote it
+
+ recolor = not colorsMatch(current, new_color)
+ retag = tag != new_hex
+ if recolor or retag:
+ pending.append((node, recolor, retag))
+
+ count = 0
+ if pending:
+ # One undo entry for the whole scene, not one per node.
+ with hou.undos.group("Update Node Colors"):
+ for node, recolor, retag in pending:
+ try:
+ if recolor:
+ node.setColor(new_color)
+ count += 1
+ if retag:
+ node.setUserData("hc_custom_color", new_hex)
+ except hou.Error:
+ continue
# 456.py calls this on every hip load, including under hython and
# batch renders, where hou.ui does not exist.
diff --git a/tools/check.py b/tools/check.py
index 771c398..8d5e560 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -763,6 +763,40 @@ def check_node_colors():
check("resetNodeColors round trip", opting_out_is_reversible)
+ def recolor_is_one_undo():
+ """A scene-wide recolor must cost one ctrl-Z, not one per node."""
+ if not hou.undos.areEnabled():
+ return "skipped: undos disabled in this interpreter"
+
+ geo = hou.node("/obj").createNode("geo")
+ nodes = []
+ for _ in range(3):
+ node = geo.createNode("box")
+ node.setUserData("hc_custom_color", "1")
+ node.setColor(RED)
+ nodes.append(node)
+
+ session = blank(HCSession)
+ hou.undos.clear()
+ session.updateNodeColors()
+
+ labels = hou.undos.undoLabels()
+ assert len(labels) == 1, f"{len(labels)} undo entries for one recolor: {labels}"
+
+ # And an unchanged scene must not leave an empty entry behind.
+ session.updateNodeColors()
+ assert hou.undos.undoLabels() == labels, \
+ f"a no-op pass pushed an undo entry: {hou.undos.undoLabels()}"
+
+ hou.undos.performUndo()
+ assert all(colorsMatch(n.color(), RED) for n in nodes), \
+ "one undo did not revert every node"
+
+ geo.destroy()
+ return f"3 nodes, 1 undo entry ({labels[0]})"
+
+ check("updateNodeColors undo grouping", recolor_is_one_undo)
+
def main():
check_settings()