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

commit4458de0059e3fbe85cb9401954e0dd0c8d4d642e
parent449e02d824
authorLucas Galante <[email protected]>
date2026-09-12 09:18
hc: make node coloring keep hand-picked colors

The `hc_custom_color` tag OnCreated.py writes was a bare "1", and
updateNodeColors() recolored every tagged node unconditionally. 456.py
calls it on every hip load, so a color picked with Set Node Colors -- or
with Houdini's own palette -- lasted exactly until the file was reopened.
replaceNode had the same hole: it copied the old node's color onto a node
OnCreated had just tagged, so the copy died on the next load too.

The tag now records the color HC last wrote. A node is recolored only
while it still wears that color; anything else means someone changed it
and it is left alone from then on. Legacy "1" tags are adopted on the
first pass and upgraded. Set Node Colors drops the tag outright, since
that is an explicit choice, and replaceNode carries the old node's tag
across with its color.

Two more things on that hip-load path: it wrote unconditionally, and
every setColor marks the hip modified, so a scene you had only opened
came up asking to be saved -- it now compares first (with a tolerance,
since node colors round-trip through float32) and the count reports real
changes. And the walk no longer recurses into locked HDAs, whose nodes
cannot be colored anyway; it was expanding every asset's contents to
discard them one at a time.

Separately, the settings panel had no control for the `color` kind
despite the schema declaring one, so it fell through to a bare line edit:
the only way to set the default node color was to type six hex digits,
and a typo was accepted and then silently replaced at read time by a
fallback that did not even match the schema default. There is now a
swatch that opens Houdini's color editor, the field refuses non-hex, an
unparseable value is never written to the file, and the fallback comes
from hcschema.

tools/check.py covers the ownership rule, the idempotence and the hex
helpers.

Co-Authored-By: Claude Opus 5 <[email protected]>

 ...op_lsgalante.developer_vector_conform.1.0.hdalc | Bin 8869 -> 8975 bytes
 ...op_lsgalante.developer_vector_migrate.1.1.hdalc | Bin 14241 -> 14167 bytes
 python3.13libs/hc/hcnetworkeditor.py               |  11 ++
 python3.13libs/hc/hcsession.py                     |  52 +++++++--
 python3.13libs/hc/hcsettings.py                    | 127 +++++++++++++++++++--
 scripts/OnCreated.py                               |   5 +-
 tools/check.py                                     |  94 +++++++++++++++
 7 files changed, 264 insertions(+), 25 deletions(-)

diff --git a/otls/sop_lsgalante.developer_vector_conform.1.0.hdalc b/otls/sop_lsgalante.developer_vector_conform.1.0.hdalc
index ed07297..620c7e8 100755
Binary files a/otls/sop_lsgalante.developer_vector_conform.1.0.hdalc and b/otls/sop_lsgalante.developer_vector_conform.1.0.hdalc differ
diff --git a/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc b/otls/sop_lsgalante.developer_vector_migrate.1.1.hdalc
index aba4f1c..412d748 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 01ee211..0bab9fd 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1174,6 +1174,14 @@ class HCNetworkEditor(HCPathTab):
             new_node.bypass(old_node.isBypassed())
             new_node.setTemplateFlag(old_node.isTemplateFlagSet())
             new_node.setColor(old_node.color())
+            # OnCreated has just tagged new_node with the default color, but it
+            # is wearing old_node's color now. Carry old_node's tag across too,
+            # or updateNodeColors() reverts the copied color on the next load.
+            old_tag = old_node.userData("hc_custom_color")
+            if old_tag is None:
+                new_node.destroyUserData("hc_custom_color", must_exist=False)
+            else:
+                new_node.setUserData("hc_custom_color", old_tag)
 
             old_node.destroy()
             new_node.setCurrent(True, clear_all_selected=True)
@@ -1233,6 +1241,9 @@ class HCNetworkEditor(HCPathTab):
             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)
 
     @command("Set Node Shapes")
     def setNodeShapes(self):
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 410d059..d024f5b 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -486,30 +486,58 @@ class HCSession:
         hou.ui.addEventLoopCallback(_callback)
 
     def updateNodeColors(self):
-        from .hcsettings import HCSettings
+        """Recolor HC-managed nodes to the configured default.
+
+        A node counts as managed while its `hc_custom_color` userdata still
+        records the color HC last wrote to it. Color it any other way -- Set
+        Node Colors, Houdini's own color palette, a paste from another scene --
+        and the record stops matching, so the node is left alone from then on.
+
+        The tag used to be a bare "1" with no record of the color, and this
+        method recolored every tagged node unconditionally. Since 456.py calls
+        it on every hip load, a hand-picked color lasted exactly until the next
+        time the file was opened.
+        """
+        from .hcsettings import HCSettings, colorsMatch, parseHex
         settings = HCSettings()
         new_color = settings.nodeColor()
+        new_hex = settings.nodeColorHex()
 
         count = 0
-        for node in hou.node("/").allSubChildren():
-            # Only nodes tagged by OnCreated.py, so hand-colored nodes survive.
-            if node.userData("hc_custom_color") != "1":
+        # 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.
+        for node in hou.node("/").allSubChildren(recurse_in_locked_nodes=False):
+            tag = node.userData("hc_custom_color")
+            if tag is None:
                 continue
             try:
-                if node.isInsideLockedHDA():
-                    continue
-                node.setColor(new_color)
+                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
-            count += 1
 
         # 456.py calls this on every hip load, including under hython and
         # batch renders, where hou.ui does not exist.
         if hou.isUIAvailable():
-            hou.ui.setStatusMessage(
-                f"Updated {count} custom-colored nodes to "
-                f"{settings.nodeGraph().get('node_color')}"
-            )
+            hou.ui.setStatusMessage(f"Recolored {count} node(s) to {new_hex}")
         return count
 
     def keycam(self):
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 66ee14f..6ba4b8e 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -3,7 +3,8 @@ import hou
 import json
 from pathlib import Path
 
-from PySide6.QtCore import QFileSystemWatcher, Qt
+from PySide6.QtCore import QFileSystemWatcher, QRegularExpression, Qt
+from PySide6.QtGui import QRegularExpressionValidator
 from PySide6.QtWidgets import (
     QCheckBox,
     QComboBox,
@@ -14,6 +15,7 @@ from PySide6.QtWidgets import (
     QHBoxLayout,
     QLabel,
     QLineEdit,
+    QPushButton,
     QScrollArea,
     QSlider,
     QSpinBox,
@@ -33,13 +35,46 @@ _cache_stat = None
 _cache_prefs = None
 
 
+def parseHex(value):
+    """'#rrggbb' (or 'rrggbb') -> an (r, g, b) tuple of floats, or None.
+
+    None means "not a color", and every caller has to decide what to do about
+    it rather than being handed a silent substitute.
+    """
+    text = str(value).strip().lstrip("#")
+    if len(text) != 6:
+        return None
+    try:
+        return tuple(int(text[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
+    except ValueError:
+        return None
+
+
+def formatHex(color):
+    """An (r, g, b) tuple or a hou.Color -> '#rrggbb'."""
+    rgb = color.rgb() if hasattr(color, "rgb") else color
+    return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
+
+
+def _defaultNodeColorRGB():
+    """The schema's node_color default, as an (r, g, b) tuple."""
+    setting = hcschema.lookup(("node_graph", "node_color"))
+    return parseHex(setting.default if setting else None) or (0.0, 0.0, 0.0)
+
+
+def colorsMatch(a, b, tolerance=1.0 / 255.0):
+    """True if two hou.Colors are equal to within one 8-bit step.
+
+    Node colors round-trip through float32, so the 0x60/255 we write does not
+    come back bit-identical and an exact compare never matches.
+    """
+    return all(abs(x - y) <= tolerance for x, y in zip(a.rgb(), b.rgb()))
+
+
 class HCSettings:
     # Generated from hcschema.SCHEMA -- add settings there, not here.
     DEFAULTS = hcschema.defaults()
 
-    def __init__(self):
-        self.node_color = (0.38, 0.38, 0.56)
-
     def _path(self):
         base = hou.getenv("HC_PATH")
         if not base:
@@ -146,16 +181,27 @@ class HCSettings:
     def nodeShape(self):
         return self.nodeGraph().get("node_shape")
 
+    def nodeColorRGB(self):
+        """The configured default node color as an (r, g, b) tuple.
+
+        An unparseable value falls back to the schema default, not to a second
+        hardcoded color -- this used to answer with a blue-purple that appears
+        nowhere in hcschema, so a typo in the hex silently produced a color the
+        settings panel never showed.
+        """
+        return parseHex(self.nodeGraph().get("node_color")) or _defaultNodeColorRGB()
+
     def nodeColor(self):
         """The configured default node color as a hou.Color."""
-        value = str(self.nodeGraph().get("node_color", "")).lstrip("#")
-        if len(value) != 6:
-            return hou.Color(self.node_color)
-        try:
-            r, g, b = (int(value[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
-        except ValueError:
-            return hou.Color(self.node_color)
-        return hou.Color((r, g, b))
+        return hou.Color(self.nodeColorRGB())
+
+    def nodeColorHex(self):
+        """The configured default node color, normalised to '#rrggbb'.
+
+        This is what gets recorded in a node's `hc_custom_color` userdata, so
+        it has to come from the same place as nodeColor().
+        """
+        return formatHex(self.nodeColorRGB())
 
 
 class HCSettingsPanel(QWidget):
@@ -315,6 +361,9 @@ class HCSettingsPanel(QWidget):
             widget.setValue(int(value))
             return widget
 
+        if kind == "color":
+            return self._makeColorWidget(setting, value)
+
         if kind == "choice":
             widget = QComboBox()
             for label, data in setting.choices:
@@ -329,6 +378,54 @@ class HCSettingsPanel(QWidget):
 
         return QLineEdit(str(value))
 
+    def _makeColorWidget(self, setting, value):
+        """A '#rrggbb' field with a swatch that opens Houdini's color editor.
+
+        `color` was declared in the schema but had no branch here, so it fell
+        through to the bare QLineEdit at the bottom of _makeWidget -- the only
+        way to set the default node color was to type six hex digits, and a
+        typo was accepted and then silently replaced by a fallback.
+        """
+        rgb = parseHex(value) or parseHex(setting.default) or (0.0, 0.0, 0.0)
+
+        container = QWidget()
+        layout = QHBoxLayout(container)
+        layout.setContentsMargins(0, 0, 0, 0)
+
+        field = QLineEdit(formatHex(rgb))
+        # Permissive enough to type through: a partial hex is valid mid-edit,
+        # and _readWidget is what refuses to write an incomplete one.
+        field.setValidator(QRegularExpressionValidator(
+            QRegularExpression("#?[0-9A-Fa-f]{0,6}"), field))
+
+        swatch = QPushButton()
+        swatch.setFixedWidth(44)
+        swatch.setToolTip("Pick a color")
+
+        def _paint():
+            parsed = parseHex(field.text())
+            # No swatch at all while the text is not a color, so a half-typed
+            # or bad value is visible rather than showing a stale color.
+            swatch.setStyleSheet(
+                f"background-color: {formatHex(parsed)}; border: 1px solid #202020;"
+                if parsed else ""
+            )
+
+        def _pick():
+            chosen = hou.ui.selectColor(hou.Color(parseHex(field.text()) or rgb))
+            if chosen is not None:  # None means the dialog was cancelled
+                field.setText(formatHex(chosen))
+
+        field.textChanged.connect(_paint)
+        swatch.clicked.connect(_pick)
+        _paint()
+
+        layout.addWidget(swatch)
+        layout.addWidget(field, 1)
+
+        container._hexfield = field
+        return container
+
     def _makeSliderWidget(self, setting, value):
         """A QSlider + QDoubleSpinBox that stay in sync."""
         vmin, vmax = setting.range
@@ -381,6 +478,12 @@ class HCSettingsPanel(QWidget):
         if isinstance(widget, QCheckBox):
             # `flag` settings round-trip as 0/1 ints, `bool` as true/false.
             return int(widget.isChecked()) if setting.kind == "flag" else widget.isChecked()
+        if hasattr(widget, "_hexfield"):
+            # Never write an unparseable color to the file: nodeColor() would
+            # fall back to the default while the panel kept showing the bad
+            # text, so the scene and the form would disagree with no sign why.
+            parsed = parseHex(widget._hexfield.text())
+            return formatHex(parsed) if parsed else setting.default
         if hasattr(widget, "_spinbox"):
             return widget._spinbox.value()
         if isinstance(widget, QDoubleSpinBox):
diff --git a/scripts/OnCreated.py b/scripts/OnCreated.py
index d71b4eb..ff630ce 100644
--- a/scripts/OnCreated.py
+++ b/scripts/OnCreated.py
@@ -6,7 +6,10 @@ settings = HCSettings()
 node_graph = settings.nodeGraph()
 
 node.setUserData("nodeshape", settings.nodeShape())
-node.setUserData("hc_custom_color", "1")
+# Record the color we apply, not a bare "1" flag: HCSession.updateNodeColors()
+# only recolors a node whose current color still matches this record, which is
+# what lets a hand-picked color survive the next hip load.
+node.setUserData("hc_custom_color", settings.nodeColorHex())
 node.setColor(settings.nodeColor())
 
 # Snap to the same grid HCNetworkEditor uses. This used to hardcode a 1.0 grid
diff --git a/tools/check.py b/tools/check.py
index 16e9124..fc99a9b 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -550,6 +550,11 @@ def check_node_ops():
         new.bypass(old.isBypassed())
         new.setTemplateFlag(old.isTemplateFlagSet())
         new.setColor(old.color())
+        old_tag = old.userData("hc_custom_color")
+        if old_tag is None:
+            new.destroyUserData("hc_custom_color", must_exist=False)
+        else:
+            new.setUserData("hc_custom_color", old_tag)
         old.destroy()
 
         assert sink.inputs()[0] == new, "downstream lost its input"
@@ -641,6 +646,94 @@ def check_panel():
     check("panel base class", panel_is_not_a_dialog)
 
 
+def check_node_colors():
+    """The rule updateNodeColors() enforces: HC owns a node's color only while
+    the node still wears the color HC last wrote to it."""
+    print("node colors")
+
+    from hc.hcsettings import colorsMatch, formatHex, parseHex
+    settings = HCSettings()
+
+    def hex_round_trips():
+        assert parseHex("#607070") == parseHex("607070"), "the # is not optional"
+        assert formatHex(parseHex("#607070")) == "#607070", "round trip lost the value"
+        assert formatHex(hou.Color((1.0, 0.0, 0.0))) == "#ff0000", "hou.Color not handled"
+        for junk in ("", "#ff", "blue", "#gggggg", None):
+            assert parseHex(junk) is None, f"{junk!r} parsed as a color"
+        return "parse/format agree, junk rejected"
+
+    check("hex helpers", hex_round_trips)
+
+    def bad_hex_falls_back_to_schema():
+        # Not to a second hardcoded color: the fallback has to be the value the
+        # settings panel shows for a missing key.
+        schema_default = hcschema.lookup(("node_graph", "node_color")).default
+        assert HCSettings._merged(
+            HCSettings.DEFAULTS, {"node_graph": {}}
+        )["node_graph"]["node_color"] == schema_default, "defaults drifted"
+        assert settings.nodeColorHex() == formatHex(settings.nodeColorRGB()), \
+            "nodeColorHex and nodeColorRGB disagree"
+        return f"schema default {schema_default}"
+
+    check("nodeColor fallback", bad_hex_falls_back_to_schema)
+
+    RED = hou.Color((1.0, 0.0, 0.0))
+    GREEN = hou.Color((0.0, 1.0, 0.0))
+
+    def only_unchanged_nodes_are_recolored():
+        default = settings.nodeColor()
+        default_hex = settings.nodeColorHex()
+        geo = hou.node("/obj").createNode("geo")
+
+        managed = geo.createNode("box")
+        managed.setUserData("hc_custom_color", default_hex)
+        managed.setColor(default)
+
+        hand = geo.createNode("box")
+        hand.setUserData("hc_custom_color", default_hex)
+        hand.setColor(RED)  # recolored since HC last wrote it
+
+        untagged = geo.createNode("box")
+        untagged.destroyUserData("hc_custom_color", must_exist=False)
+        untagged.setColor(GREEN)
+
+        legacy = geo.createNode("box")
+        legacy.setUserData("hc_custom_color", "1")  # pre-record tag
+        legacy.setColor(GREEN)
+
+        blank(HCSession).updateNodeColors()
+
+        assert colorsMatch(managed.color(), default), "managed node was not maintained"
+        assert colorsMatch(hand.color(), RED), "hand-picked color was reverted"
+        assert colorsMatch(untagged.color(), GREEN), "untagged node was recolored"
+        assert colorsMatch(legacy.color(), default), "legacy '1' tag was not adopted"
+        assert legacy.userData("hc_custom_color") == default_hex, \
+            "legacy tag was not upgraded to the color record"
+
+        geo.destroy()
+        return "hand-picked colors survive, legacy tags adopted"
+
+    check("updateNodeColors ownership", only_unchanged_nodes_are_recolored)
+
+    def second_pass_writes_nothing():
+        """Every setColor marks the hip modified, and 456.py runs this on load."""
+        geo = hou.node("/obj").createNode("geo")
+        node = geo.createNode("box")
+        node.setUserData("hc_custom_color", "1")
+        node.setColor(RED)
+
+        session = blank(HCSession)
+        first = session.updateNodeColors()
+        second = session.updateNodeColors()
+        assert first >= 1, "the node needing a recolor was not counted"
+        assert second == 0, f"{second} redundant write(s) on an unchanged scene"
+
+        geo.destroy()
+        return "idempotent: no writes when nothing changed"
+
+    check("updateNodeColors is idempotent", second_pass_writes_nothing)
+
+
 def main():
     check_settings()
     check_panel()
@@ -651,6 +744,7 @@ def main():
     check_nodegraph_hooks()
     check_geometry()
     check_node_ops()
+    check_node_colors()
     print(f"\n{passed} passed, {failed} failed")
     return 1 if failed else 0