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

commit71c8c6655cd46cd3d9d39aa31dd0132662f87e8e
parentf6b9597431
authorLucas Galante <[email protected]>
date2026-09-18 00:58
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