SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: let node coloring and the network cursor be switched off
node_graph.node_coloring is new. Off, OnCreated leaves a node the colour
Houdini gave it and writes no tag -- a tag written while off would make
the node managed again the moment the switch flips back, which is not
what "off" should leave behind -- and updateNodeColors() returns without
touching anything. Nodes tagged earlier keep their tags, so turning it
back on resumes maintaining exactly the set it was maintaining before.
Set Node Colors and Reset Node Colors still work either way: invoking
those is asking for a colour outright.
node_graph.hcnetcursor already existed and the drawing code already
honoured it, but nothing else did. nodegraphhooks kept moving the
invisible cursor to every click and refitting it to the selection, so
turning the cursor back on jumped it to wherever you last clicked. Worse,
it still swallowed the unmodified f key to frame a cursor that was not
there -- so with the cursor off, f did nothing at all instead of falling
back to Houdini's frame-selection.
The guard for the selection refit goes inside _syncSelection rather than
at its call sites, because _PendingSelectionSyncAction queues it to run
later; gating only the call sites left the delayed path refitting.
tools/check.py reads nodegraphhooks with ast and fails if any cursor call
sits outside a gate -- it is what caught that path.
Co-Authored-By: Claude Opus 5 <[email protected]>
hc_settings.json | 1 +
python3.13libs/hc/hcschema.py | 11 ++++-
python3.13libs/hc/hcsession.py | 4 ++
python3.13libs/hc/hcsettings.py | 10 ++++
python3.13libs/nodegraphhooks.py | 16 +++++-
scripts/OnCreated.py | 10 +++-
tools/check.py | 103 +++++++++++++++++++++++++++++++++++++++
7 files changed, 151 insertions(+), 4 deletions(-)
diff --git a/hc_settings.json b/hc_settings.json
index 98173fd..cf1af15 100644
--- a/hc_settings.json
+++ b/hc_settings.json
@@ -32,6 +32,7 @@
},
"node_graph": {
"node_shape": "rect",
+ "node_coloring": true,
"node_color": "#607070",
"zoom_center": "hc_cursor",
"hcnetcursor": true,
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index d0a4059..6f7201c 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -99,9 +99,18 @@ SCHEMA = {
},
"node_graph": {
"node_shape": Setting("choice", "rect", choices=tuple((s, s) for s in NODE_SHAPES)),
+ "node_coloring": Setting(
+ "bool", True, label="Custom Node Coloring",
+ help="Color new nodes with the colour below and keep them in sync. "
+ "Off leaves every node the colour Houdini gives it.",
+ ),
"node_color": Setting("color", "#607070"),
"zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS),
- "hcnetcursor": Setting("bool", True, label="hcnetcursor"),
+ "hcnetcursor": Setting(
+ "bool", True, label="hcnetcursor",
+ help="The grid cursor in the network editor. Off also returns the "
+ "unmodified f key to Houdini's own frame-selection.",
+ ),
"current_node_arrow_color": Setting(
"color", "#618f8f", label="Current Node Arrow Color",
help="The off-screen current-node arrow drawn over the network editor.",
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index eb8ecf9..2f51a4a 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -506,6 +506,10 @@ class HCSession:
"""
from .hcsettings import HCSettings, colorsMatch, parseHex
settings = HCSettings()
+ if not settings.nodeColoringEnabled():
+ # Nodes already carrying a tag keep it, so turning coloring back on
+ # resumes maintaining exactly the nodes it was maintaining before.
+ return 0
new_color = settings.nodeColor()
new_hex = settings.nodeColorHex()
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 5c5714f..6d9cd78 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -178,6 +178,16 @@ class HCSettings:
def hcnetcursorEnabled(self):
return bool(self.nodeGraph().get("hcnetcursor"))
+ def nodeColoringEnabled(self):
+ """Whether HC colors nodes at all.
+
+ Governs the automatic half: the colour and tag OnCreated applies, and
+ the maintenance pass in HCSession.updateNodeColors(). Set Node Colors
+ and Reset Node Colors still work -- invoking those is asking for a
+ colour outright.
+ """
+ return bool(self.nodeGraph().get("node_coloring"))
+
def nodeShape(self):
return self.nodeGraph().get("node_shape")
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index dc006d5..50e5449 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -3,6 +3,7 @@ from canvaseventtypes import *
import nodegraphbase as base
import nodegraphdisplay as display
from hc import HCNetworkEditor
+from hc import HCSettings
from hc import hcstate
# Modifier state belongs to the pane; the selection signature is compared
@@ -13,6 +14,11 @@ _selections = hcstate.Store("nodegraph_selection", hcstate.NETWORK)
def _syncSelection(hc_editor, editor):
"""Refit the hcnetcursor when the selection envelope has changed."""
+ # Guard here rather than only at the call sites: this also runs from
+ # _PendingSelectionSyncAction, queued earlier and run later, so a call site
+ # check alone leaves the delayed path refitting a cursor nobody can see.
+ if not HCSettings().hcnetcursorEnabled():
+ return
signature = hc_editor.selectedNodesEnvelopeSignature()
if signature == _selections.get(editor):
return
@@ -63,7 +69,11 @@ def createEventHandler(uievent, pending_actions):
hc_editor.updateCurrentNodeOverlay()
if isinstance(uievent, MouseEvent):
- if editor is not None:
+ # Everything below drives the hcnetcursor. With it off there is nothing
+ # to move or refit: doing it anyway kept an invisible cursor tracking
+ # the mouse and the selection, so turning it back on jumped it to
+ # wherever you had last clicked.
+ if editor is not None and HCSettings().hcnetcursorEnabled():
if uievent.eventtype == 'mousedown' and not uievent.located:
hc_editor.moveHcnetcursorToPosition(uievent.mousepos)
_syncSelection(hc_editor, editor)
@@ -84,6 +94,10 @@ def createEventHandler(uievent, pending_actions):
if (
editor is not None
and (key == "f" or rawkey == "f")
+ # Without the cursor there is nothing to frame, and swallowing f
+ # here left the key doing nothing at all rather than falling back
+ # to Houdini's frame-selection.
+ and HCSettings().hcnetcursorEnabled()
):
modifierstate = getattr(uievent, "modifierstate", None)
has_modifier = (
diff --git a/scripts/OnCreated.py b/scripts/OnCreated.py
index ff630ce..a319188 100644
--- a/scripts/OnCreated.py
+++ b/scripts/OnCreated.py
@@ -6,11 +6,17 @@ settings = HCSettings()
node_graph = settings.nodeGraph()
node.setUserData("nodeshape", settings.nodeShape())
+
# 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())
+#
+# With coloring off, leave no colour and no tag at all -- a tag written now
+# would make the node managed again the moment the setting is turned back on,
+# which is not what "off" should leave behind.
+if settings.nodeColoringEnabled():
+ 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
# with a 0.5/0.85 offset, so any non-default grid_*_step left every new node
diff --git a/tools/check.py b/tools/check.py
index 70c45cc..f47ea23 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -960,6 +960,108 @@ def check_current_node():
check("guards stay gone", guards_stay_gone)
+def check_disable_switches():
+ """Both features have to be switchable off, all the way off.
+
+ hcnetcursor was already declared and already honoured by the drawing code,
+ but nodegraphhooks kept moving the invisible cursor and kept swallowing the
+ unmodified f key -- so "off" left f doing nothing instead of Houdini's
+ frame-selection.
+ """
+ print("disable switches")
+
+ settings = HCSettings()
+
+ def both_are_declared():
+ for key in ("node_coloring", "hcnetcursor"):
+ setting = hcschema.lookup(("node_graph", key))
+ assert setting is not None, f"node_graph.{key} is not declared"
+ assert setting.kind == "bool", f"{key} is {setting.kind}, not a checkbox"
+ assert setting.default is True, f"{key} defaults off -- it must default on"
+ return "node_coloring and hcnetcursor, both bool, both default on"
+
+ check("declared and default on", both_are_declared)
+
+ def coloring_off_is_a_no_op():
+ geo = hou.node("/obj").createNode("geo")
+ node = geo.createNode("box")
+ node.setUserData("hc_custom_color", "1") # would be recolored
+ node.setColor(hou.Color((1.0, 0.0, 0.0)))
+
+ session = blank(HCSession)
+ original = HCSettings.nodeColoringEnabled
+ try:
+ HCSettings.nodeColoringEnabled = lambda self: False
+ assert session.updateNodeColors() == 0, "recolored while switched off"
+ assert node.userData("hc_custom_color") == "1", "rewrote the tag while off"
+ finally:
+ HCSettings.nodeColoringEnabled = original
+
+ # And the tag is still there, so turning it back on resumes.
+ assert session.updateNodeColors() >= 1, "did not resume when switched on"
+ geo.destroy()
+ return "no writes while off, resumes after"
+
+ check("node_coloring off", coloring_off_is_a_no_op)
+
+ def hooks_gate_on_the_setting():
+ """The hook runs per UI event and cannot be driven without a pane, so
+ read the structure instead: every hcnetcursor call it makes has to sit
+ under an `if` that tests the setting."""
+ import ast
+
+ source = (ROOT / "python3.13libs" / "nodegraphhooks.py").read_text()
+ tree = ast.parse(source)
+
+ def calls_under(node):
+ """Names of hcnetcursor methods called anywhere beneath node."""
+ out = set()
+ for sub in ast.walk(node):
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute):
+ if "hcnetcursor" in sub.func.attr.lower():
+ out.add(sub.func.attr)
+ return out
+
+ cursor_calls = calls_under(tree)
+ # updateCurrentNodeOverlay is deliberately ungated: it is what removes
+ # the cursor image when the setting is turned off, and it also draws
+ # the off-screen arrow, which has nothing to do with the cursor.
+ must_gate = {"moveHcnetcursorToPosition", "fitHcnetcursorToSelectedNodes",
+ "frameHcnetcursor", "refreshHcnetcursor"}
+ present = cursor_calls & must_gate
+ assert present, f"no cursor calls found to check, only {sorted(cursor_calls)}"
+
+ gated = set()
+ for node in ast.walk(tree):
+ # Calls inside `if ...hcnetcursorEnabled...:`
+ if isinstance(node, ast.If) and "hcnetcursorEnabled" in ast.unparse(node.test):
+ gated |= calls_under(node)
+ # ...or anywhere in a function that opens with an early-return guard.
+ if isinstance(node, ast.FunctionDef):
+ for stmt in node.body:
+ if (isinstance(stmt, ast.If)
+ and "hcnetcursorEnabled" in ast.unparse(stmt.test)
+ and any(isinstance(b, ast.Return) for b in stmt.body)):
+ gated |= calls_under(node)
+ break
+
+ # refreshHcnetcursor sits in the modifier-change branch and is a redraw,
+ # not a cursor move -- it is allowed through like updateCurrentNodeOverlay.
+ required = present - {"refreshHcnetcursor"}
+ missing = required - gated
+ assert not missing, f"not gated on hcnetcursorEnabled: {sorted(missing)}"
+ return "gated: " + ", ".join(sorted(required))
+
+ check("hcnetcursor off", hooks_gate_on_the_setting)
+
+ def accessors_read_the_file():
+ for name in ("nodeColoringEnabled", "hcnetcursorEnabled"):
+ assert getattr(settings, name)() in (True, False), f"{name} is not a bool"
+ return "both accessors return bools"
+
+ check("accessors", accessors_read_the_file)
+
+
def check_startup_script():
"""scripts/123.py reads hc_settings.json without importing hc.
@@ -1022,6 +1124,7 @@ def main():
check_node_ops()
check_node_colors()
check_current_node()
+ check_disable_switches()
check_startup_script()
print(f"\n{passed} passed, {failed} failed")
return 1 if failed else 0