SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
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()