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

commit31ac61475e80d140149f934f96ff4c718149ef1b
parentb1c5d7bacb
authorLucas Galante <[email protected]>
date2026-09-12 09:49
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