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

commit406998c694a9ef8c05bfaa4a14856a9a1a09b683
parent5677d77cac
authorLucas Galante <[email protected]>
date2026-09-14 14:39
neteditor: drop a node onto another to swap them

nodegraphhooks records the node under a plain left-button press and its
position, and a pending action completes on the mouseup Houdini's move
handler consumes, after the new position is written. If the dragged node's
centre now sits in another node's grid cell, that node moves into the cell
the dragged one left. Two nodes wired directly to each other also trade
places in the chain: the downstream one inherits the upstream one's inputs
and other outputs, the upstream one the downstream one's other inputs and
outputs, and the link between them reverses. A node that cannot take the
inputs it would inherit keeps its wiring and the editor says why.

Skipped for multi-node, copy, tree and selection-modifying drags, and
gated on a drop_swap setting. check.py drives both the cell swap and the
chain swap through a scratch network.

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

 python3.13libs/hc/hcnetworkeditor.py | 120 +++++++++++++++++++++++++++++
 python3.13libs/hc/hcschema.py        |   6 ++
 python3.13libs/hc/hcsettings.py      |   4 +
 python3.13libs/nodegraphhooks.py     |  58 ++++++++++++++
 tools/check.py                       | 145 +++++++++++++++++++++++++++++++++++
 5 files changed, 333 insertions(+)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 013f668..d67d911 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -916,6 +916,126 @@ class HCNetworkEditor(HCPathTab):
         self.hou_tab.flashMessage(
             None, f"Snapped {moved} of {len(nodes)} nodes", 1.0)
 
+    def swapDroppedNode(self, node_path, start_pos):
+        """Finish a drag that dropped one node onto another: swap positions.
+
+        nodegraphhooks records the node under the mouse and its position at
+        mousedown and calls this on the mouseup that ends the drag, after
+        Houdini's move handler has written the new position. If the dragged
+        node's centre now sits in another node's grid cell, that node moves
+        into the cell the dragged one vacated. When the two are wired directly
+        to each other they also trade places in the chain (see
+        _swapChainPlaces); otherwise wiring is left alone. Returns the
+        displaced node, or None when nothing was swapped.
+        """
+        if not HCSettings().dropSwapEnabled():
+            return None
+        node = hou.node(node_path)
+        pwd = self.hou_tab.pwd()
+        if node is None or node.parent() != pwd:
+            return None
+        start = hou.Vector2(start_pos[0], start_pos[1])
+        end = node.position()
+        if (end - start).length() < 1e-6:
+            return None
+
+        # Same cell: centres within half a grid step on both axes. Under
+        # hard snapping they coincide exactly; the tolerance covers a drop
+        # with snapping off, where "onto" means mostly overlapping.
+        step = self._gridStep()
+        offset = self._nodeOffset()
+        center = end + offset
+        target = None
+        best = None
+        for other in pwd.children():
+            if other == node:
+                continue
+            other_center = other.position() + offset
+            dx = abs(other_center[0] - center[0])
+            dy = abs(other_center[1] - center[1])
+            if dx < step[0] * 0.5 and dy < step[1] * 0.5:
+                dist = dx + dy
+                if best is None or dist < best:
+                    target, best = other, dist
+        if target is None:
+            return None
+
+        with hou.undos.group("Swap Nodes"):
+            target.setPosition(start)
+            pair = self._linkedPair(node, target)
+            if pair is not None:
+                problem = self._swapChainPlaces(*pair)
+                if problem:
+                    self.hou_tab.flashMessage(None, problem, 2.0)
+        return target
+
+    def _linkedPair(self, a, b):
+        """(upstream, downstream) when one feeds the other directly, else None."""
+        for conn in b.inputConnections():
+            if conn.inputItem() == a:
+                return (a, b)
+        for conn in a.inputConnections():
+            if conn.inputItem() == b:
+                return (b, a)
+        return None
+
+    @staticmethod
+    def _connect(consumer, index, item, output_index):
+        # A network dot has a single input and no index for it.
+        if isinstance(consumer, hou.NetworkDot):
+            consumer.setInput(item, output_index)
+        else:
+            consumer.setInput(index, item, output_index)
+
+    def _swapChainPlaces(self, up, down):
+        """Make `down` take `up`'s place in the graph and vice versa.
+
+        P -> up -> down -> Q becomes P -> down -> up -> Q: down inherits up's
+        inputs and up's other outputs, up inherits down's other inputs and
+        down's outputs, and the link between them reverses. Returns a message
+        when the swap cannot be made (a node has too few inputs for the
+        connections it would inherit) and leaves the wiring untouched then.
+        """
+        def source(conn):
+            return (conn.inputIndex(), conn.inputItem(), conn.inputItemOutputIndex())
+
+        def sink(conn):
+            return (conn.outputItem(), conn.inputIndex(), conn.inputItemOutputIndex())
+
+        up_in = [source(c) for c in up.inputConnections()]
+        down_in = [source(c) for c in down.inputConnections()]
+        up_out = [sink(c) for c in up.outputConnections() if c.outputItem() != down]
+        down_out = [sink(c) for c in down.outputConnections()]
+
+        for node, inherited in ((down, up_in), (up, down_in)):
+            need = max((idx for idx, _, _ in inherited), default=-1) + 1
+            if need > node.type().maxNumInputs():
+                return f"Cannot swap chain: {node.name()} has too few inputs"
+
+        def clamp_output(item, index):
+            names = item.outputNames() if isinstance(item, hou.Node) else ()
+            return index if index < len(names) else 0
+
+        for idx, _, _ in up_in:
+            up.setInput(idx, None)
+        for idx, _, _ in down_in:
+            down.setInput(idx, None)
+        for consumer, idx, _ in up_out + down_out:
+            self._connect(consumer, idx, None, 0)
+
+        for idx, item, out in up_in:
+            down.setInput(idx, item, out)
+        for idx, item, out in down_in:
+            if item == up:
+                up.setInput(idx, down, clamp_output(down, out))
+            else:
+                up.setInput(idx, item, out)
+        for consumer, idx, out in up_out:
+            self._connect(consumer, idx, down, clamp_output(down, out))
+        for consumer, idx, out in down_out:
+            self._connect(consumer, idx, up, clamp_output(up, out))
+        return None
+
     def sweepToGrid(self):
         """Snap every off-grid node in the displayed network; return the count.
 
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index 24642f3..d0a91e1 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -115,6 +115,12 @@ SCHEMA = {
             "color", "#618f8f", label="Current Node Arrow Color",
             help="The off-screen current-node arrow drawn over the network editor.",
         ),
+        "drop_swap": Setting(
+            "bool", True, label="Drop to Swap",
+            help="Dragging a node onto another node's cell moves that node "
+                 "into the cell the dragged one left. Two nodes wired directly "
+                 "to each other also trade places in the chain.",
+        ),
         "grid_snap": Setting(
             "bool", True, label="Hard Grid Snap",
             help="Every drag, Tab-menu placement and box resize lands on the "
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index c5fab61..843bdb1 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -178,6 +178,10 @@ class HCSettings:
     def hcnetcursorEnabled(self):
         return bool(self.nodeGraph().get("hcnetcursor"))
 
+    def dropSwapEnabled(self):
+        """Dropping a node on another node's cell swaps their positions."""
+        return bool(self.nodeGraph().get("drop_swap", True))
+
     def gridSnapEnabled(self):
         """Hard grid snapping: the wide snap radius and the post-action sweep."""
         return bool(self.nodeGraph().get("grid_snap", True))
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index 1927191..74bbee8 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -62,6 +62,61 @@ class _PendingSnapAction(base.PendingAction):
         return done
 
 
+class _PendingSwapAction(base.PendingAction):
+    """After a plain drag of one node ends, swap it with whatever it landed on.
+
+    Queued on the mousedown that starts the drag, which is the last event the
+    hook sees before Houdini's move handler takes over; completes on the
+    mouseup that handler consumes, after it has written the new position.
+    """
+
+    def __init__(self, editor, node_path, start_pos):
+        base.PendingAction.__init__(self)
+        self.editor = editor
+        self.node_path = node_path
+        self.start_pos = start_pos
+
+    def completeAction(self, uievent):
+        if not (isinstance(uievent, MouseEvent) and uievent.eventtype == 'mouseup'):
+            return False
+        # Alt was down at mousedown too when this was a copy drag, but the
+        # copy check here catches a modifier pressed mid-drag as well.
+        if not getattr(uievent.modifierstate, "alt", False):
+            HCNetworkEditor(self.editor).swapDroppedNode(self.node_path, self.start_pos)
+        return True
+
+
+# The located names Houdini's own NodeClickHandler treats as the node body;
+# a press on an input, output or flag starts a wire or a toggle, not a move.
+_NODE_BODY_SELECTORS = ('node', 'connectorarea', 'preview', 'footer',
+                        'containerinput', 'containeroutput')
+
+
+def _queueDropSwap(editor, uievent, pending_actions):
+    if not HCSettings().dropSwapEnabled():
+        return
+    mods = uievent.modifierstate
+    if getattr(mods, "alt", False) or getattr(mods, "ctrl", False) or getattr(mods, "shift", False):
+        return  # copy, tree or selection-modifying drags: not a plain move
+    if not getattr(uievent.mousestate, "lmb", False):
+        return
+    selected = uievent.selected
+    if selected is None or selected.name not in _NODE_BODY_SELECTORS:
+        return
+    node = selected.item
+    if not isinstance(node, hou.Node) or node.parent() != editor.pwd():
+        return
+    # A press on one of several selected nodes drags the whole selection;
+    # which of them "landed on" what is ambiguous, so only a lone node swaps.
+    if node.isSelected() and len(editor.pwd().selectedItems()) > 1:
+        return
+    for action in pending_actions:
+        if isinstance(action, _PendingSwapAction) and action.editor == editor:
+            return
+    pos = node.position()
+    pending_actions.append(_PendingSwapAction(editor, node.path(), (pos[0], pos[1])))
+
+
 def _queueSnapSweep(editor, pending_actions, from_keyboard):
     if not HCSettings().gridSnapEnabled():
         return
@@ -105,7 +160,10 @@ def createEventHandler(uievent, pending_actions):
 
     if isinstance(uievent, MouseEvent):
         if editor is not None and uievent.eventtype == 'mousedown':
+            # Order matters: both complete on the same mouseup, and the swap
+            # should see the dragged node's swept, final position.
             _queueSnapSweep(editor, pending_actions, from_keyboard=False)
+            _queueDropSwap(editor, uievent, pending_actions)
         # Everything below drives the hcnetcursor. With it off there is nothing
         # to move or refit: doing it anyway kept an invisible cursor tracking
         # the mouse and the selection, so turning it back on jumped it to
diff --git a/tools/check.py b/tools/check.py
index 4f5d172..9802032 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -786,6 +786,151 @@ def check_node_ops():
 
     check("post-action sweep", sweep_snaps_the_network)
 
+    def drop_swaps_positions():
+        """swapDroppedNode: a node dragged into another node's cell sends
+        that node to the cell it left. Same cell only, nearest wins when
+        cells overlap, wiring untouched, gated on drop_swap."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            a = geo.createNode("null", "a")
+            b = geo.createNode("null", "b")
+            c = geo.createNode("null", "c")
+            b.setInput(0, a)
+
+            class FakePane:
+                def id(self):
+                    return 987658
+
+            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()
+            for node, cell in ((a, (0, 0)), (b, (1, 0)), (c, (3, 0))):
+                node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
+                editor.snapToGrid(node)
+            a_start, b_start, c_start = a.position(), b.position(), c.position()
+
+            # Simulate the drag: a lands exactly on b's cell.
+            a.setPosition(b_start)
+            assert editor.swapDroppedNode(a.path(), (a_start[0], a_start[1])) == b, "b was not displaced"
+            assert b.position().isAlmostEqual(a_start), f"b went to {b.position()}, not {a_start}"
+            assert a.position().isAlmostEqual(b_start), "the dragged node must stay where it was dropped"
+            # a fed b, so they trade places in the chain: b now feeds a.
+            assert a.inputs() == (b,) and not b.inputs(), \
+                f"linked pair did not swap places: a<-{a.inputs()}, b<-{b.inputs()}"
+            assert c.position().isAlmostEqual(c_start), "a bystander moved"
+
+            # No movement, or a drop on an empty cell, swaps nothing.
+            assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped without a move"
+            a.setPosition(hou.Vector2(c_start[0] - 2 * step[0], c_start[1]))
+            assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped on an empty cell"
+
+            original = HCSettings.dropSwapEnabled
+            try:
+                HCSettings.dropSwapEnabled = lambda _self: False
+                a.setPosition(c_start)
+                assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped with drop_swap off"
+            finally:
+                HCSettings.dropSwapEnabled = original
+            return "a onto b: b takes a's cell and a's place in the chain; no-ops for no move, empty cell, setting off"
+        finally:
+            geo.destroy()
+
+    check("drop to swap", drop_swaps_positions)
+
+    def linked_swap_trades_chain_places():
+        """P -> X -> Y -> Q with a side output X -> Z and a second input W -> Y:
+        dropping Y onto X must give P -> Y -> X -> Q, Y -> Z, W -> X. Unlinked
+        nodes only trade cells, and a node that cannot take the inputs it
+        would inherit leaves the wiring alone."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            P, W, Z, Q = (geo.createNode("null", n) for n in ("P", "W", "Z", "Q"))
+            X = geo.createNode("merge", "X")
+            Y = geo.createNode("merge", "Y")
+            X.setInput(0, P)
+            Y.setInput(0, X)
+            Y.setInput(1, W)
+            Z.setInput(0, X)
+            Q.setInput(0, Y)
+
+            class FakePane:
+                def id(self):
+                    return 987659
+
+            class FakeTab:
+                def __init__(self):
+                    self.flashed = []
+
+                def pane(self):
+                    return FakePane()
+
+                def setPref(self, name, value):
+                    pass
+
+                def pwd(self):
+                    return geo
+
+                def flashMessage(self, image, message, duration):
+                    self.flashed.append(message)
+
+            tab = FakeTab()
+            editor = ne.HCNetworkEditor(tab)
+            step = editor._gridStep()
+            for node, cell in ((P, (0, 2)), (X, (0, 1)), (Y, (0, 0)), (Q, (0, -1)),
+                               (W, (1, 1)), (Z, (1, 0))):
+                node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
+                editor.snapToGrid(node)
+            x_start, y_start = X.position(), Y.position()
+
+            def wiring(node):
+                return sorted((c.inputIndex(), c.inputItem().name()) for c in node.inputConnections())
+
+            Y.setPosition(x_start)  # the drag: Y dropped onto X
+            assert editor.swapDroppedNode(Y.path(), (y_start[0], y_start[1])) == X
+            assert X.position().isAlmostEqual(y_start), "X did not take Y's cell"
+            assert wiring(Y) == [(0, "P")], f"Y should inherit X's input P, has {wiring(Y)}"
+            assert wiring(X) == [(0, "Y"), (1, "W")], f"X should take Y's place, has {wiring(X)}"
+            assert wiring(Z) == [(0, "Y")], f"X's side output should now come from Y, has {wiring(Z)}"
+            assert wiring(Q) == [(0, "X")], f"Y's output should now come from X, has {wiring(Q)}"
+            assert not tab.flashed, f"unexpected message: {tab.flashed}"
+
+            # Unlinked: W onto Z trades cells only.
+            w_start, z_start = W.position(), Z.position()
+            W.setPosition(z_start)
+            assert editor.swapDroppedNode(W.path(), (w_start[0], w_start[1])) == Z
+            assert Z.position().isAlmostEqual(w_start)
+            assert wiring(Z) == [(0, "Y")] and wiring(X) == [(0, "Y"), (1, "W")], "unlinked swap touched wiring"
+
+            # Infeasible: Q (one input) onto X (which now has two inputs) would
+            # hand Q two inputs. Cells swap, wiring stays, and it says why.
+            q_start, x_now = Q.position(), X.position()
+            Q.setPosition(x_now)
+            assert editor.swapDroppedNode(Q.path(), (q_start[0], q_start[1])) == X
+            assert X.position().isAlmostEqual(q_start), "cells should still swap"
+            assert wiring(X) == [(0, "Y"), (1, "W")] and wiring(Q) == [(0, "X")], "infeasible swap changed wiring"
+            assert tab.flashed and "too few inputs" in tab.flashed[-1], f"no explanation: {tab.flashed}"
+            return "P->Y->X->Q with Y->Z and W->X; unlinked pair keeps wiring; infeasible pair explained"
+        finally:
+            geo.destroy()
+
+    check("linked swap trades chain places", linked_swap_trades_chain_places)
+
     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