git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit55537c4a2d611c1ed10121f12f88e87b853659bb
parenta83f530b31
authorLucas Galante <[email protected]>
date2026-09-12 09:22
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()