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

commit5fdbc7320efd9fdf273f269e7c23046090d1b6ae
parentd49bd196f8
authorLucas Galante <[email protected]>
date2026-09-12 09:25
hc: pull the last hardcoded color into the schema, unhook Reload Colors

Two loose ends in the node color work.

The off-screen current-node arrow drawn over the network editor carried
the only hardcoded hou.Color left in the package. It is now a declared
`color` setting, which means it gets a swatch in the settings panel for
free. Resolving it needed a per-color accessor rather than another
nodeColor()-shaped method, so HCSettings.color/colorRGB/colorHex take a
schema path and fall back to that setting's own declared default --
nodeColor() is now one line on top of it.

Reload Colors called updateNodeColors(), so rereading a .hcs file off
disk also rewrote node colors throughout the open scene. They share a
word and nothing else: one reloads config, the other edits the hip.
Update Node Colors already had its own menu item and is now a @command
too, so unhooking it costs no reach.

tools/check.py asserts every declared color default actually parses, that
each color falls back to its own default rather than a shared one, and
that the arrow kept the value it was hardcoded to.

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

 python3.13libs/hc/hcnetworkeditor.py |  2 +-
 python3.13libs/hc/hcschema.py        |  4 ++++
 python3.13libs/hc/hcsession.py       |  9 ++++++++-
 python3.13libs/hc/hcsettings.py      | 32 ++++++++++++++++++++----------
 tools/check.py                       | 38 ++++++++++++++++++++++++++++++++++++
 5 files changed, 73 insertions(+), 12 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 5239660..e44a494 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -211,7 +211,7 @@ class HCNetworkEditor(HCPathTab):
             tip_y - length * math.sin(angle + math.atan(width_ratio))
         )
 
-        color = hou.Color((0.38, 0.56, 0.56))
+        color = HCSettings().color("node_graph", "current_node_arrow_color")
         width = 3.0
         return [
             hou.NetworkShapeLine(p1, p2, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index e69a8df..d408668 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -97,6 +97,10 @@ SCHEMA = {
         "node_color":  Setting("color", "#607070"),
         "zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS),
         "hcnetcursor": Setting("bool", True, label="hcnetcursor"),
+        "current_node_arrow_color": Setting(
+            "color", "#618f8f", label="Current Node Arrow Color",
+            help="The off-screen current-node arrow drawn over the network editor.",
+        ),
         "grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0)),
         "grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0)),
         "node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0)),
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index fd3c36e..b5cb39d 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -360,9 +360,15 @@ class HCSession:
 
     @command("Reload Colors")
     def reloadColorSchemes(self):
+        """Reload the UI and viewport color schemes from config/.
+
+        This used to call updateNodeColors() as well, so rereading a .hcs file
+        also rewrote node colors throughout the open scene. They share a word
+        and nothing else: one reloads config off disk, the other edits the hip.
+        Update Node Colors is its own command and its own menu item.
+        """
         hou.ui.reloadColorScheme()
         hou.ui.reloadViewportColorSchemes()
-        self.updateNodeColors()
 
     @command("HC Info")
     def hcInfo(self):
@@ -485,6 +491,7 @@ class HCSession:
         setattr(hou.session, callback_name, _callback)
         hou.ui.addEventLoopCallback(_callback)
 
+    @command("Update Node Colors")
     def updateNodeColors(self):
         """Recolor HC-managed nodes to the configured default.
 
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 6ba4b8e..5c5714f 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -56,9 +56,9 @@ def formatHex(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"))
+def _schemaDefaultRGB(path):
+    """The declared default of a `color` setting, as an (r, g, b) tuple."""
+    setting = hcschema.lookup(path)
     return parseHex(setting.default if setting else None) or (0.0, 0.0, 0.0)
 
 
@@ -181,15 +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.
+    def colorRGB(self, *path):
+        """A `color` setting as an (r, g, b) tuple, addressed by schema path.
 
-        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.
+        An unparseable value falls back to that setting's own declared default,
+        never to a hardcoded stand-in somewhere else -- node_color 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()
+        stored = self.section(*path[:-1]).get(path[-1])
+        return parseHex(stored) or _schemaDefaultRGB(path)
+
+    def color(self, *path):
+        """A `color` setting as a hou.Color."""
+        return hou.Color(self.colorRGB(*path))
+
+    def colorHex(self, *path):
+        """A `color` setting normalised to '#rrggbb'."""
+        return formatHex(self.colorRGB(*path))
+
+    def nodeColorRGB(self):
+        return self.colorRGB("node_graph", "node_color")
 
     def nodeColor(self):
         """The configured default node color as a hou.Color."""
diff --git a/tools/check.py b/tools/check.py
index 8d5e560..5544202 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -664,6 +664,44 @@ def check_node_colors():
 
     check("hex helpers", hex_round_trips)
 
+    def every_color_default_parses():
+        """A `color` whose default is not a color hands the panel, and every
+        caller, a fallback that is itself broken."""
+        found = []
+
+        def walk(schema, path):
+            for key, value in schema.items():
+                if isinstance(value, dict):
+                    walk(value, path + (key,))
+                elif value.kind == "color":
+                    assert parseHex(value.default) is not None, \
+                        f"{'.'.join(path + (key,))} default {value.default!r} is not a color"
+                    found.append(".".join(path + (key,)))
+
+        walk(hcschema.SCHEMA, ())
+        assert found, "no color settings declared"
+        return ", ".join(found)
+
+    check("color defaults", every_color_default_parses)
+
+    def fallback_is_per_setting():
+        """Each color falls back to its own declared default, not a shared one."""
+        arrow = ("node_graph", "current_node_arrow_color")
+        node = ("node_graph", "node_color")
+        assert settings.colorHex(*arrow) != settings.colorHex(*node), \
+            "two colors resolved to the same value"
+        for path in (arrow, node):
+            declared = hcschema.lookup(path).default
+            # A key absent from the file must still resolve to its own default.
+            assert formatHex(parseHex(declared)) == settings.colorHex(*path), \
+                f"{path[-1]} did not resolve to its declared default"
+        # The arrow color has to still be the value it was hardcoded to.
+        assert colorsMatch(settings.color(*arrow), hou.Color((0.38, 0.56, 0.56))), \
+            "the arrow color moved when it was pulled into the schema"
+        return f"{settings.colorHex(*arrow)} arrow, {settings.colorHex(*node)} node"
+
+    check("per-setting color fallback", fallback_is_per_setting)
+
     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.