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

commit5677d77caccc1791102daf76d7bd7edceb20a51e
parent0e094d31d1
authorLucas Galante <[email protected]>
date2026-09-14 14:24
neteditor: highlight the node under the hcnetcursor after a hip load

The cursor state is keyed by pane and network path, so an editor showing
the same path after a load kept the previous scene's cursor, and a fresh
one was only created lazily while drawing; neither ran the selection sync,
so the node under the cursor sat unhighlighted until the first cursor key.
456.py now resets every editor's cursor one event-loop turn after the load.

Right after a load no child carries the current flag yet, though the
editor still names the node the file saved as current -- measured live,
currentNode() said growth_vis while every isCurrent() was False. The
initial anchor falls back to that node when it is a child of pwd(), so the
cursor lands where the file left off rather than nearest the view centre.

The deferred callback is a shared helper now, and a no-op headless: the
first version traced back in hython running 456.py for a batch load.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 python3.13libs/hc/hcnetworkeditor.py | 38 +++++++++++++++
 python3.13libs/hc/hcsession.py       | 34 +++++++++++--
 scripts/456.py                       |  5 ++
 tools/check.py                       | 94 ++++++++++++++++++++++++++++++++++--
 4 files changed, 165 insertions(+), 6 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index c8b09cb..013f668 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -242,6 +242,13 @@ class HCNetworkEditor(HCPathTab):
         step = self._gridStep()
         anchor = None
         current = self.currentNode()
+        if current is None:
+            # Right after a hip load no child carries the current flag yet,
+            # but the editor still names the node the file saved as current.
+            # Measured live: currentNode() was growth_vis while every child's
+            # isCurrent() was False. Anchoring on it puts the cursor where the
+            # file left off rather than on whatever is nearest the view centre.
+            current = self._editorCurrentChild()
         if current is not None:
             anchor = current.position()
         if anchor is None:
@@ -259,6 +266,23 @@ class HCNetworkEditor(HCPathTab):
             "grow_y": 0,
         }
 
+    def _editorCurrentChild(self):
+        """hou.NetworkEditor.currentNode(), only when it is a child of pwd().
+
+        The editor's answer is its last-known current node and can be stale
+        (see currentNode); it names the network itself when nothing inside
+        is current. Good enough as a fallback anchor, never as the truth.
+        """
+        try:
+            node = self.hou_tab.currentNode()
+        except hou.Error:
+            return None
+        if node is None or node == self.hou_tab.pwd():
+            return None
+        if node.parent() != self.hou_tab.pwd():
+            return None
+        return node
+
     def _hcnetcursorRectFromState(self, state):
         if "origin" not in state and "center" not in state:
             return hou.BoundingRect(
@@ -620,6 +644,20 @@ class HCNetworkEditor(HCPathTab):
         self._syncHcnetcursor()
 
 
+    def resetHcnetcursor(self):
+        """Drop the cursor state and rebuild it around the current node.
+
+        After a hip load the store still holds the previous scene's cursor for
+        this pane and path, or the overlay creates a fresh one lazily while
+        drawing -- either way nothing re-ran the selection sync, so the node
+        under the cursor sat unhighlighted until the first cursor key.
+        """
+        if not HCSettings().hcnetcursorEnabled():
+            return
+        _cursors.pop(self.hou_tab)
+        _overlays.pop(self.hou_tab)
+        self._syncHcnetcursor()
+
     def resetHcnetcursorSize(self):
         state = self._hcnetcursorState()
         state["grow_x"] = 0
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index aac8024..01327f2 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -530,8 +530,16 @@ class HCSession:
                 print(f"[HCSession] Failed to initialize network editor: {e!r}")
         return initialized
 
-    def initializeNetworkEditorsDeferred(self):
-        callback_name = "_hc_init_network_editors_callback"
+    def _deferToEventLoop(self, callback_name, fn):
+        """Run fn once on the next event-loop turn.
+
+        The pending callback is published on hou.session under callback_name
+        so a repeat call replaces it rather than stacking a second run.
+        Headless (hython running 456.py for a batch load) there is no event
+        loop and no pane to act on, so this is a no-op there.
+        """
+        if not hou.isUIAvailable():
+            return
         existing = getattr(hou.session, callback_name, None)
         if existing is not None:
             try:
@@ -541,7 +549,7 @@ class HCSession:
 
         def _callback():
             try:
-                self.initializeNetworkEditors()
+                fn()
             finally:
                 try:
                     hou.ui.removeEventLoopCallback(_callback)
@@ -553,6 +561,26 @@ class HCSession:
         setattr(hou.session, callback_name, _callback)
         hou.ui.addEventLoopCallback(_callback)
 
+    def initializeNetworkEditorsDeferred(self):
+        self._deferToEventLoop("_hc_init_network_editors_callback",
+                               self.initializeNetworkEditors)
+
+    def resetHcnetcursors(self):
+        reset = 0
+        for editor in self.allNetworkEditors():
+            try:
+                editor.resetHcnetcursor()
+                reset += 1
+            except Exception as e:
+                print(f"[HCSession] Failed to reset hcnetcursor: {e!r}")
+        return reset
+
+    def resetHcnetcursorsDeferred(self):
+        """456.py runs while the load is still settling; the editors show the
+        loaded file's networks by the next event-loop turn."""
+        self._deferToEventLoop("_hc_reset_hcnetcursors_callback",
+                               self.resetHcnetcursors)
+
     def updateNodeColors(self):
         """Recolor HC-managed nodes to the configured default.
 
diff --git a/scripts/456.py b/scripts/456.py
index 192ab03..4c61a40 100644
--- a/scripts/456.py
+++ b/scripts/456.py
@@ -19,3 +19,8 @@ if Path(current_file).is_file():
         json.dump(updates, f, indent=4)
 
 HCSession().updateNodeColors()
+# The hcnetcursor state is keyed by pane and network path, so an editor
+# showing the same path as before the load keeps the old scene's cursor, and
+# a fresh one is only created lazily while drawing. Neither highlights what
+# is under it until a cursor key runs the selection sync; do that now.
+HCSession().resetHcnetcursorsDeferred()
diff --git a/tools/check.py b/tools/check.py
index 48cbd60..4f5d172 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -832,6 +832,61 @@ def check_node_ops():
 
     check("pans reach the editor", pans_reach_the_editor)
 
+    def initial_cursor_anchors_on_the_editor_current_node():
+        """After a hip load no child has the current flag, but the editor still
+        names the file's current node; the initial cursor must anchor there,
+        not on the node nearest the view centre."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            near = geo.createNode("null", "near")
+            far = geo.createNode("null", "far")
+            near.setPosition(hou.Vector2(0.0, 0.0))
+            far.setPosition(hou.Vector2(20.0, 20.0))
+            assert not far.isCurrent() and not near.isCurrent(), "a fresh node is already current"
+
+            class FakePane:
+                def id(self):
+                    return 987657
+
+            class FakeTab:
+                def __init__(self, current):
+                    self.current = current
+
+                def pane(self):
+                    return FakePane()
+
+                def setPref(self, name, value):
+                    pass
+
+                def pwd(self):
+                    return geo
+
+                def currentNode(self):
+                    return self.current
+
+                def visibleBounds(self):
+                    # Centred on `near`, so the nearest-node fallback would pick it.
+                    return hou.BoundingRect(-5.0, -5.0, 5.0, 5.0)
+
+            state = ne.HCNetworkEditor(FakeTab(far))._initialHcnetcursorState()
+            rect = ne.HCNetworkEditor(FakeTab(far))._hcnetcursorRectFromState(state)
+            assert rect.contains(far.position() + hou.Vector2(0.5, 0.15)), \
+                f"cursor {rect} is not over the editor's current node at {far.position()}"
+
+            # The editor naming the network itself, or a node elsewhere, is no anchor.
+            for stale in (geo, hou.node("/obj")):
+                state = ne.HCNetworkEditor(FakeTab(stale))._initialHcnetcursorState()
+                rect = ne.HCNetworkEditor(FakeTab(stale))._hcnetcursorRectFromState(state)
+                assert rect.contains(near.position() + hou.Vector2(0.5, 0.15)), \
+                    f"with editor current {stale.path()} the cursor went to {rect}, not the nearest node"
+            return "anchors on the editor's current child; falls back to nearest otherwise"
+        finally:
+            geo.destroy()
+
+    check("initial cursor anchor after load", initial_cursor_anchors_on_the_editor_current_node)
+
     def pwd_is_a_real_node():
         """HCPathTab.pwd() returned an HCNode with no path(), so path() -- and
         the Show Path Message command through it -- raised AttributeError."""
@@ -1189,9 +1244,20 @@ def check_current_node():
         for gone in ("current.parent() != ed.pwd()",
                      "current.parent() == self.hou_tab.pwd()"):
             assert gone not in source, f"the guard {gone!r} is back"
-        assert "self.hou_tab.currentNode()" not in source, \
-            "a caller bypasses the wrapper and gets the editor's stale answer"
-        return "no caller bypasses the wrapper"
+        # One reader of the editor's answer is allowed: _editorCurrentChild,
+        # the post-load fallback anchor for the hcnetcursor, which documents
+        # that it is reading a possibly stale value and only uses it when it
+        # is a child of pwd(). Nothing else may bypass the wrapper.
+        import ast
+        tree = ast.parse(source)
+        readers = set()
+        for func in ast.walk(tree):
+            if isinstance(func, ast.FunctionDef):
+                if "self.hou_tab.currentNode()" in ast.unparse(func):
+                    readers.add(func.name)
+        assert readers <= {"_editorCurrentChild"}, \
+            f"callers bypass the wrapper and get the editor's stale answer: {sorted(readers)}"
+        return "only _editorCurrentChild reads the editor's answer"
 
     check("guards stay gone", guards_stay_gone)
 
@@ -1344,6 +1410,28 @@ def check_startup_script():
 
     check("startup prompt gate", prompt_is_gated_on_the_setting)
 
+    def post_load_script_calls_real_methods():
+        """scripts/456.py runs after every hip load and, like 123.py, cannot
+        be imported here without running it. Every HCSession method it names
+        must exist -- a rename would only surface as a traceback on the next
+        file open -- and the hcnetcursor reset must be among them: without it
+        the node under the cursor sat unhighlighted after a load."""
+        path = ROOT / "scripts" / "456.py"
+        tree = ast.parse(path.read_text())
+        called = set()
+        for node in ast.walk(tree):
+            if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
+                    and isinstance(node.func.value, ast.Call)
+                    and getattr(node.func.value.func, "id", None) == "HCSession"):
+                called.add(node.func.attr)
+        assert called, "456.py no longer calls HCSession()"
+        missing = sorted(name for name in called if not callable(getattr(HCSession, name, None)))
+        assert not missing, f"456.py calls HCSession methods that do not exist: {missing}"
+        assert "resetHcnetcursorsDeferred" in called, "456.py does not reset the hcnetcursor after a load"
+        return "calls " + ", ".join(sorted(called))
+
+    check("456.py calls real methods", post_load_script_calls_real_methods)
+
 
 def main():
     check_settings()