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

commit810811442fbebcba28206349fb47cb0eb5e48298
parent9ec40f7e95
authorLucas Galante <[email protected]>
date2026-09-16 12:29
hc: tolerate a network editor with no network

An editor handed the event hook an event while its pwd() was None -- seen
live while floating pane tabs were being created and closed -- and the
overlay reached currentNode() through the hook, which raised
AttributeError on every event until the editor had a network again.
Houdini drops an event the hook raises on.

The hook now lets such an event through untouched, before wrapping the
editor. currentNode() and the overlay answer None / draw nothing without a
network, and the nearest-node anchor does the same, so the same state
reached from a command or a reload is harmless too. check.py drives
currentNode() and the overlay with a stub whose pwd() is None, and asserts
the hook's guard precedes the wrapper in source, since nodegraphhooks
cannot be imported without hou.ui.

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

 python3.13libs/hc/hcnetworkeditor.py | 10 ++++++++-
 python3.13libs/nodegraphhooks.py     |  7 +++++++
 tools/check.py                       | 39 ++++++++++++++++++++++++++++++++++++
 3 files changed, 55 insertions(+), 1 deletion(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index ce11003..10e326f 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -516,6 +516,9 @@ class HCNetworkEditor(HCPathTab):
         calling redraw() unconditionally made panning visibly stutter; skip
         the work when the resulting picture would be identical.
         """
+        if self.hou_tab.pwd() is None:
+            # Nothing to anchor the cursor to and nothing to draw an arrow at.
+            return
         enabled = HCSettings().hcnetcursorEnabled()
         rect = self._hcnetcursorDrawRect(self.hcnetcursorRect()) if enabled else None
         cursor_path = self._hcnetcursorImageFile() if enabled else None
@@ -538,11 +541,14 @@ class HCNetworkEditor(HCPathTab):
         self.updateCurrentNodeOverlay(force=True)
 
     def _nearestNodeToViewportCenter(self):
+        network = self.hou_tab.pwd()
+        if network is None:
+            return None
         bounds = self.bounds()
         center = bounds.center()
         best = None
         best_dist = float("inf")
-        for node in self.hou_tab.pwd().children():
+        for node in network.children():
             npos = node.position()
             dx = npos[0] - center[0]
             dy = npos[1] - center[1]
@@ -686,6 +692,8 @@ class HCNetworkEditor(HCPathTab):
         network on every mouse move would not be free.
         """
         network = self.hou_tab.pwd()
+        if network is None:
+            return None
         for node in network.selectedChildren():
             if node.isCurrent():
                 return node
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index c94d4d9..a236151 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -162,6 +162,13 @@ def _queueSelectionSync(editor, pending_actions):
 def createEventHandler(uievent, pending_actions):
     editor = getattr(uievent, "editor", None)
 
+    # An editor can hand out an event while it has no network -- seen live as
+    # pwd() returning None while floating pane tabs were being created and
+    # closed. Everything below reads the network, and a traceback out of this
+    # hook makes Houdini drop the event, so let it have the event untouched.
+    if editor is not None and editor.pwd() is None:
+        return None, False
+
     if editor is not None:
         hc_editor = HCNetworkEditor(editor)
         modifierstate = getattr(uievent, "modifierstate", None)
diff --git a/tools/check.py b/tools/check.py
index 4bd06d4..57d5830 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -1634,6 +1634,45 @@ def check_current_node():
 
     check("currentNode goes None", nothing_current_is_none)
 
+    def no_network_is_none():
+        """pwd() can be None -- seen live while floating pane tabs were being
+        created and closed -- and the overlay reached currentNode() through
+        the event hook, which raised on every event until the editor had a
+        network again."""
+        editor = blank(HCNetworkEditor)
+        editor.hou_tab = _StubTab(None)
+        assert editor.currentNode() is None, "answered without a network"
+        return "None without a network"
+
+    check("currentNode without a network", no_network_is_none)
+
+    def overlay_skips_without_network():
+        """The stub has pwd() and nothing else: touching the editor to draw
+        raises AttributeError, so returning quietly is the only way through."""
+        editor = blank(HCNetworkEditor)
+        editor.hou_tab = _StubTab(None)
+        editor.updateCurrentNodeOverlay()
+        editor.updateCurrentNodeOverlay(force=True)
+        return "draws nothing"
+
+    check("overlay without a network", overlay_skips_without_network)
+
+    def hook_skips_without_network():
+        """The event hook lets the event through untouched rather than
+        reading a network the editor has not got. nodegraphhooks cannot be
+        imported without hou.ui (Houdini's nodegraphbase needs it at import),
+        so this reads the source: the guard has to come before the hook
+        wraps the editor."""
+        source = (ROOT / "python3.13libs" / "nodegraphhooks.py").read_text()
+        body = source[source.index("def createEventHandler("):]
+        guard = body.find("editor.pwd() is None")
+        wrap = body.find("HCNetworkEditor(editor)")
+        assert guard != -1, "the hook no longer checks for a missing network"
+        assert wrap != -1 and guard < wrap, "the hook wraps the editor before checking its network"
+        return "guard precedes the wrapper"
+
+    check("hook without a network", hook_skips_without_network)
+
     def guards_stay_gone():
         """The `current.parent()` guards only fired in the fallback case, and
         threw the answer away when they did. currentNode() now returns a child