SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: spell out the currentNode staleness in the docstrings and check
What testing Rename Node on a real SOP settled: which cases the editor's
answer is actually wrong in, and which of the ten callers each case
reached. The docstrings carried a rougher account of that.
check.py gains the headless stand-in for the staleness -- children
present, none of them current, answer None rather than a leftover -- and
the guard check is renamed, since `current.parent() != pwd()` is not
always true: it fires only in the fallback case.
Co-Authored-By: Claude Opus 5 <[email protected]>
python3.13libs/hc/hcnetworkeditor.py | 42 ++++++++++++++-----------
tools/check.py | 59 +++++++++++++++++++++++++++---------
2 files changed, 68 insertions(+), 33 deletions(-)
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 3bfd86b..9f7d63d 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -641,24 +641,30 @@ class HCNetworkEditor(HCPathTab):
self.expandHcnetcursor(direction)
def currentNode(self):
- """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.
+ """The node that is current *now*, inside the displayed network, or None.
+
+ hou.NetworkEditor.currentNode() does not answer that. It answers with
+ the editor's last-known current node, which goes stale two ways: it
+ keeps naming a node after that node has stopped being current, and when
+ the editor has no current child in the displayed network it returns the
+ network itself. Measured in a live session: with nothing current it
+ still named the node that used to be, and at /obj it named /obj.
+
+ Callers guarded the second case with `current.parent() == pwd()` --
+ which is `pwd().parent() == pwd()`, so when it fired it discarded the
+ answer entirely, and the off-screen arrow never drew at /obj at all.
+ Nothing guarded the first, so Rename Node and the flag toggles would
+ act on whatever had been current a moment ago.
+
+ 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(), 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
+ 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():
diff --git a/tools/check.py b/tools/check.py
index 78bc770..70c45cc 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -837,15 +837,21 @@ def check_node_colors():
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.
+ """HCNetworkEditor.currentNode() has to answer with the node that is
+ current *now*, inside the displayed network.
+
+ hou.NetworkEditor.currentNode() answers with the editor's last-known
+ current node instead. 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 there. So Rename Node and the flag toggles could act on
+ whatever had been current a moment ago, and at /obj -- where the fallback
+ applies -- the off-screen arrow never drew at all, its guard
+ `current.parent() != pwd()` reducing to `pwd().parent() != pwd()`.
+
+ The staleness lives in the editor, so it cannot be reproduced without a
+ pane. What is checkable here is the rule the fix rests on: read isCurrent()
+ off the children, and answer None when none of them is current. currentNode()
+ only touches hou_tab.pwd(), so a stub tab is enough to drive it.
"""
print("current node")
@@ -918,17 +924,40 @@ def check_current_node():
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."""
+ def nothing_current_is_none():
+ """The headless stand-in for the editor's staleness: children exist,
+ none of them is current, so the answer is None rather than a leftover."""
+ geo = hou.node("/obj").createNode("geo")
+ node = geo.createNode("box")
+ editor = blank(HCNetworkEditor)
+ editor.hou_tab = _StubTab(geo)
+
+ node.setCurrent(True, clear_all_selected=True)
+ assert editor.currentNode() == node, "precondition: the node is current"
+
+ node.setSelected(False) # also clears current
+ assert not node.isCurrent(), "precondition: nothing is current now"
+ assert editor.currentNode() is None, \
+ "answered with a node that is no longer current"
+
+ geo.destroy()
+ return "None once nothing is current"
+
+ check("currentNode goes None", nothing_current_is_none)
+
+ 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
+ of pwd() or None, so there is nothing left for them to check."""
source = (ROOT / "python3.13libs" / "hc" / "hcnetworkeditor.py").read_text()
- for dead in ("current.parent() != ed.pwd()",
+ for gone 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 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 network again"
+ "a caller bypasses the wrapper and gets the editor's stale answer"
return "no caller bypasses the wrapper"
- check("no dead guards", no_dead_parent_guards)
+ check("guards stay gone", guards_stay_gone)
def check_startup_script():