SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: make currentNode mean the node that is current now
hou.NetworkEditor.currentNode() answers with the editor's last-known
current node, and that goes stale two ways: it keeps naming a node after
that node stops being current, and it falls back to the network itself
when the editor has no current child in the displayed network. Measured
in a live session -- with nothing current it still named the node that
used to be, and at /obj it named /obj.
HCNetworkEditor.currentNode() delegated straight to it. Five callers
acted on whatever it handed back:
renameNode renamed the node that had been current
setDisplayFlag flagged it
toggleBypassFlag bypassed it when nothing was selected
toggleTemplateFlag templated it
floatingParameterEditor opened its parms (hcsession)
Five more wrote `current.parent() == pwd()` guards against the fallback
case, which is `pwd().parent() == pwd()` -- so when the guard fired it
discarded the answer entirely:
_currentNodeOverlayShapes the off-screen arrow never drew at /obj
_hcnetcursorOverlayKey arrow always None there
_initialHcnetcursorState never anchored on the current node
_traversalTarget never started from the current node
component navigation never detected being inside a component
hou.Node.isCurrent() is authoritative and immediate, but it is a
per-child flag, so the node has to be searched for. Being current implies
being selected -- selecting another node moves current with it,
deselecting clears it -- so selectedChildren() finds it in one call; the
children() scan is the safety net for if that stops holding, which
matters because this runs from the overlay on every network editor UI
event. tools/check.py asserts the invariant, so the fast path cannot
quietly stop being sufficient.
Verified in a live session: the off-screen arrow returns its three
NetworkShapeLines where it returned none, and Rename Node declines
instead of renaming a node that is no longer current.
Co-Authored-By: Claude Opus 5 <[email protected]>
python3.13libs/hc/hcnetworkeditor.py | 44 +++++++++++++----
tools/check.py | 96 ++++++++++++++++++++++++++++++++++++
2 files changed, 131 insertions(+), 9 deletions(-)
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index e429aa2..3bfd86b 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -58,7 +58,7 @@ class HCNetworkEditor(HCPathTab):
left/right = cycle through nodes sharing a downstream child (ordered
by x-position)."""
if from_node is None:
- from_node = self.hou_tab.currentNode()
+ from_node = self.currentNode()
# Check if from_node is visible in the current viewport
is_visible = False
@@ -157,8 +157,8 @@ class HCNetworkEditor(HCPathTab):
def _currentNodeOverlayShapes(self):
"""Return an arrow pointing at the current node when it is off-screen."""
ed = self.hou_tab
- current = ed.currentNode()
- if current is None or current.parent() != ed.pwd():
+ current = self.currentNode()
+ if current is None:
return []
rect = ed.itemRect(current)
@@ -250,8 +250,8 @@ class HCNetworkEditor(HCPathTab):
def _initialHcnetcursorState(self):
step = self._gridStep()
anchor = None
- current = self.hou_tab.currentNode()
- if current is not None and current.parent() == self.hou_tab.pwd():
+ current = self.currentNode()
+ if current is not None:
anchor = current.position()
if anchor is None:
nearest = self._nearestNodeToViewportCenter()
@@ -494,8 +494,8 @@ class HCNetworkEditor(HCPathTab):
round(rmax[0], 4), round(rmax[1], 4), cursor_path)
ed = self.hou_tab
- current = ed.currentNode()
- if current is None or current.parent() != ed.pwd():
+ current = self.currentNode()
+ if current is None:
arrow = None
else:
center = ed.itemRect(current).center()
@@ -641,7 +641,33 @@ class HCNetworkEditor(HCPathTab):
self.expandHcnetcursor(direction)
def currentNode(self):
- return self.hou_tab.currentNode()
+ """The current node *inside* the displayed network, or None.
+
+ hou.NetworkEditor.currentNode() does not mean this: it returns the
+ network being displayed, the same object as pwd(). So every caller here
+ was handed the network itself, and the `current.parent() == pwd()`
+ guards written around it -- pwd().parent() == pwd() -- were never true.
+ Half the callers silently fell back to something else; the other half
+ acted on the network. Rename Node renamed the network you were inside.
+
+ Houdini exposes the real current node only as a per-child isCurrent()
+ flag, so it has to be searched for. Being current implies being
+ selected -- selecting another node moves current with it, deselecting
+ clears it -- so selectedChildren(), one call returning a short list,
+ finds it every time in practice. tools/check.py asserts that invariant;
+ the children() scan below is the safety net for if it ever stops
+ holding, not the normal path. It matters because this runs from the
+ overlay on every network editor UI event, and scanning a large network
+ on every mouse move would not be free.
+ """
+ network = self.hou_tab.pwd()
+ for node in network.selectedChildren():
+ if node.isCurrent():
+ return node
+ for node in network.children():
+ if node.isCurrent():
+ return node
+ return None
@command("Deselect All")
def deselectAllNodes(self):
@@ -728,7 +754,7 @@ class HCNetworkEditor(HCPathTab):
return (cx, -cy)
selected_set = set(self.hou_tab.pwd().selectedChildren())
- current = self.hou_tab.currentNode()
+ current = self.currentNode()
ref_comp = None
# 1. Determine if we are currently "inside" a component
diff --git a/tools/check.py b/tools/check.py
index ff001b8..78bc770 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -836,6 +836,101 @@ def check_node_colors():
check("updateNodeColors undo grouping", recolor_is_one_undo)
+def check_current_node():
+ """HCNetworkEditor.currentNode() has to mean the current node *inside* the
+ network, not the network itself.
+
+ hou.NetworkEditor.currentNode() returns pwd(), so the wrapper used to hand
+ every caller the network: Rename Node renamed the network you were inside,
+ the flag toggles flagged it, and the off-screen arrow -- whose guard read
+ `current.parent() != pwd()`, i.e. `pwd().parent() != pwd()` -- never drew
+ at all. currentNode() only touches hou_tab.pwd(), so a stub tab is enough
+ to drive it without a pane.
+ """
+ print("current node")
+
+ class _StubTab:
+ def __init__(self, node):
+ self._node = node
+
+ def pwd(self):
+ return self._node
+
+ def finds_the_current_child():
+ geo = hou.node("/obj").createNode("geo")
+ a = geo.createNode("box")
+ b = geo.createNode("sphere")
+ editor = blank(HCNetworkEditor)
+ editor.hou_tab = _StubTab(geo)
+
+ b.setCurrent(True, clear_all_selected=True)
+ found = editor.currentNode()
+ assert found == b, f"expected the current child, got {found}"
+ assert found != geo, "handed back the network itself"
+
+ a.setCurrent(True, clear_all_selected=True)
+ assert editor.currentNode() == a, "did not follow the current node"
+
+ geo.destroy()
+ return "follows the current child"
+
+ check("currentNode finds the child", finds_the_current_child)
+
+ def current_implies_selected():
+ """currentNode() looks through selectedChildren() first, which is only
+ a fast path as long as the current node is always in there."""
+ geo = hou.node("/obj").createNode("geo")
+ a = geo.createNode("box")
+ b = geo.createNode("sphere")
+
+ a.setCurrent(True, clear_all_selected=True)
+ assert a.isSelected(), "setCurrent did not select"
+
+ # Selecting another node moves current with it...
+ b.setSelected(True)
+ assert b.isCurrent() and not a.isCurrent(), "current did not follow selection"
+
+ # ...and deselecting clears it, so there is no current-but-unselected
+ # node for the fast path to miss.
+ b.setSelected(False)
+ assert not b.isCurrent(), "a deselected node stayed current"
+
+ for node in (a, b):
+ node.setCurrent(True, clear_all_selected=True)
+ assert node in geo.selectedChildren(), (
+ "the current node is not in selectedChildren -- the fast path "
+ "in HCNetworkEditor.currentNode() is no longer sufficient"
+ )
+
+ geo.destroy()
+ return "current is always selected, so the fast path suffices"
+
+ check("current implies selected", current_implies_selected)
+
+ def empty_network_is_none():
+ geo = hou.node("/obj").createNode("geo")
+ editor = blank(HCNetworkEditor)
+ editor.hou_tab = _StubTab(geo)
+ assert editor.currentNode() is None, \
+ "an empty network reported a current node"
+ geo.destroy()
+ return "None, not the network"
+
+ check("currentNode on an empty network", empty_network_is_none)
+
+ def no_dead_parent_guards():
+ """`current.parent() != pwd()` is always true -- it must not come back."""
+ source = (ROOT / "python3.13libs" / "hc" / "hcnetworkeditor.py").read_text()
+ for dead in ("current.parent() != ed.pwd()",
+ "current.parent() == self.hou_tab.pwd()"):
+ assert dead not in source, f"the always-{'true' if '!=' in dead else 'false'} guard {dead!r} is back"
+ assert "self.hou_tab.currentNode()" not in source, \
+ "a caller bypasses the wrapper and gets the network again"
+ return "no caller bypasses the wrapper"
+
+ check("no dead guards", no_dead_parent_guards)
+
+
def check_startup_script():
"""scripts/123.py reads hc_settings.json without importing hc.
@@ -897,6 +992,7 @@ def main():
check_geometry()
check_node_ops()
check_node_colors()
+ check_current_node()
check_startup_script()
print(f"\n{passed} passed, {failed} failed")
return 1 if failed else 0