SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
network editor: alt+x excises the current node from its chain
Excise Node rewires every consumer of the current node to whatever fed
the node's lowest connected input, so P -> N -> Q becomes P -> Q, then
unplugs the node and steps it to the nearest free cell on its right,
still current, so it can be walked elsewhere with alt+hjkl or deleted.
With nothing feeding it the consumers are simply unplugged. Unlike
Delete it keeps the node and its parameters. One undo step.
Menu item in NetworkViewMenu.xml, alt+x in hc_hotkeys.json, and a
headless check with two consumers and an occupied cell to the right.
Like every menu hotkey symbol, alt+x registers at the next Houdini
start; the command is in the HC Panel straight away.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
NetworkViewMenu.xml | 8 ++++++
hc_hotkeys.json | 1 +
python3.13libs/hc/hcnetworkeditor.py | 38 +++++++++++++++++++++++++
tools/check.py | 55 ++++++++++++++++++++++++++++++++++++
4 files changed, 102 insertions(+)
diff --git a/NetworkViewMenu.xml b/NetworkViewMenu.xml
index 06fd68c..fb75044 100644
--- a/NetworkViewMenu.xml
+++ b/NetworkViewMenu.xml
@@ -385,6 +385,14 @@ HCNetworkEditor(kwargs['editor']).run('Toggle Template', 'toggleTemplateFlag')
<scriptCode><![CDATA[
from hc import HCNetworkEditor
HCNetworkEditor(kwargs['editor']).run('Set Display', 'setDisplayFlag')
+]]></scriptCode>
+ </scriptItem>
+
+ <scriptItem id="pane.wsheet.hc_excise_node">
+ <label>Excise Node</label>
+ <scriptCode><![CDATA[
+from hc import HCNetworkEditor
+HCNetworkEditor(kwargs['editor']).run('Excise Node', 'exciseNode')
]]></scriptCode>
</scriptItem>
diff --git a/hc_hotkeys.json b/hc_hotkeys.json
index f3f4fd1..d915b31 100644
--- a/hc_hotkeys.json
+++ b/hc_hotkeys.json
@@ -103,6 +103,7 @@
"inputfield.home": "ctrl+a",
"h.hc_session_toggle_spreadsheet": "alt+p",
"h.pane.wsheet.hc_replace_node": "alt+r",
+ "h.pane.wsheet.hc_excise_node": "alt+x",
"h.pane.wsheet.hc_frame_cursor": "f"
}
}
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 095b2c5..2c73d15 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1580,6 +1580,44 @@ class HCNetworkEditor(HCPathTab):
""" Node replacement """
+ @command("Excise Node")
+ def exciseNode(self):
+ """Take the current node out of its chain and leave the chain whole.
+
+ Every consumer of the node is rewired to what fed the node's lowest
+ connected input, so P -> N -> Q becomes P -> Q (with nothing feeding
+ N, the consumers are simply unplugged). N keeps its parameters,
+ loses its wires and steps aside to the nearest free cell on its
+ right, still current, ready to be walked elsewhere with alt+hjkl or
+ deleted. Unlike Delete it keeps the node.
+ """
+ node = self.currentNode()
+ if node is None:
+ hou.ui.setStatusMessage("No current node to excise",
+ hou.severityType.Warning)
+ return
+ feeds = sorted(node.inputConnections(), key=lambda c: c.inputIndex())
+ feed = (feeds[0].inputItem(), feeds[0].inputItemOutputIndex()) if feeds else (None, 0)
+ with hou.undos.group("Excise Node"):
+ for conn in list(node.outputConnections()):
+ self._connect(conn.outputItem(), conn.inputIndex(), feed[0], feed[1])
+ for conn in feeds:
+ node.setInput(conn.inputIndex(), None)
+ node.setPosition(self._freeCellRightOf(node))
+ node.setCurrent(True, clear_all_selected=True)
+
+ def _freeCellRightOf(self, node):
+ """Position for `node` in the nearest empty cell to its right."""
+ step = self._gridStep()
+ offset = self._nodeOffset()
+ taken = {self._cellKey(other.position() + offset)
+ for other in node.parent().children() if other != node}
+ x, y = self._cellKey(node.position() + offset)
+ x += 1
+ while (x, y) in taken:
+ x += 1
+ return hou.Vector2(x * step[0], y * step[1]) - offset
+
@command("Replace Node")
def replaceNode(self):
"""Open a fuzzy-search node type picker and replace the single
diff --git a/tools/check.py b/tools/check.py
index 0d79bf7..8b361dd 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -1241,6 +1241,61 @@ def check_node_ops():
check("group step reorders the chain", group_step_reorders_chain)
+ def excise_heals_the_chain():
+ """exciseNode (alt+x): the current node leaves its chain, its
+ consumers take its feed, and it steps to the nearest free cell on
+ its right, still current. With nothing feeding it the consumers are
+ unplugged."""
+ from hc import hcnetworkeditor as ne
+
+ geo = hou.node("/obj").createNode("geo")
+ try:
+ P, N, Q, R, S = (geo.createNode("null", n) for n in ("P", "N", "Q", "R", "S"))
+ N.setInput(0, P); Q.setInput(0, N); R.setInput(0, N)
+
+ class FakePane:
+ def id(self):
+ return 987662
+
+ class FakeTab:
+ def pane(self):
+ return FakePane()
+
+ def setPref(self, name, value):
+ pass
+
+ def pwd(self):
+ return geo
+
+ def flashMessage(self, *args):
+ pass
+
+ editor = ne.HCNetworkEditor(FakeTab())
+ step = editor._gridStep()
+
+ def place(node, cell):
+ node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
+ editor.snapToGrid(node)
+
+ for node, cell in ((P, (0, 3)), (N, (0, 2)), (S, (1, 2)), (Q, (0, 1)), (R, (1, 1))):
+ place(node, cell)
+ two_right = N.position() + hou.Vector2(2 * step[0], 0.0)
+ N.setCurrent(True, clear_all_selected=True)
+ editor.exciseNode()
+ assert Q.inputs() == (P,) and R.inputs() == (P,), "consumers did not take N's feed"
+ assert not N.inputs() and not N.outputs(), "N still wired"
+ assert N.position().isAlmostEqual(two_right), f"N should skip S's cell, is at {N.position()}"
+ assert N.isCurrent() and geo.selectedChildren() == (N,), "N is not the current node"
+
+ P.setCurrent(True, clear_all_selected=True)
+ editor.exciseNode()
+ assert Q.inputs() == () and R.inputs() == (), "consumers of an unfed node were not unplugged"
+ return "P -> N -> {Q, R} became P -> {Q, R}, N two cells right past S; unfed P unplugs its consumers"
+ finally:
+ geo.destroy()
+
+ check("excise heals the chain", excise_heals_the_chain)
+
def pans_reach_the_editor():
"""hou.NetworkEditor.setVisibleBounds drops a change that keeps the
zoom unless set_center_when_scale_rejected is passed (the docs say