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

commitf6b959743167aee04223ac7884262e6458f3aca3
parent156c56c78e
authorLucas Galante <[email protected]>
date2026-09-18 00:51
network editor: a stepped selection trades places with the nodes it lands on

alt+hjkl moved a lone node onto a neighbour's cell and swapped with it,
cells and chain places, like a mouse drop; several selected nodes only
moved, piling onto whatever they landed on.

The step now treats the moving nodes as runs along its line. A run whose
head lands on a bystander sends the bystander to the cell freed at the
run's tail, and when the bystander is wired to the run it trades chain
places with each node of the run in turn: P -> a -> b -> c -> Q with
{a, b} stepped down onto c becomes P -> c -> a -> b -> Q with c in a's
old cell. A run of one is the old swap, so the lone case is unchanged
and still respects Drop to Swap. Each row or column is its own run, so a
selection stepped sideways swaps pairwise. The whole step is one undo.

Mouse drops of several nodes still only move, as before.

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

 python3.13libs/hc/hcnetworkeditor.py | 78 ++++++++++++++++++++++--------
 tools/check.py                       | 93 ++++++++++++++++++++++++++++++++++--
 2 files changed, 149 insertions(+), 22 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index fc27c17..095b2c5 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1103,6 +1103,51 @@ class HCNetworkEditor(HCPathTab):
                 self._outputDescendants(output, visited)
         return visited
 
+    def _cellKey(self, center):
+        """The grid cell holding a node centre, as integer coordinates."""
+        step = self._gridStep()
+        return (int(round(center[0] / step[0])), int(round(center[1] / step[1])))
+
+    def _displaceOverrun(self, moving, origins, delta):
+        """Nodes the stepping group landed on trade places with it.
+
+        `origins` maps the cell each moving node left to the node. Along the
+        line of the step, the moving nodes form runs; a run whose head lands
+        on a bystander pushes it to the cell freed at the run's tail, and
+        when the bystander is wired to the run it trades chain places with
+        each node of the run in turn, so P -> a -> b -> c -> Q with {a, b}
+        stepped down onto c becomes P -> c -> a -> b -> Q. A run of one is
+        the swap a mouse drop makes. Before this only a lone node swapped;
+        a selection stepped onto its neighbour just piled onto it.
+        """
+        if not origins or not HCSettings().dropSwapEnabled():
+            return
+        step = self._gridStep()
+        offset = self._nodeOffset()
+        dkey = (int(round(delta[0] / step[0])), int(round(delta[1] / step[1])))
+        landed = {(x + dkey[0], y + dkey[1]): node for (x, y), node in origins.items()}
+        for other in self.hou_tab.pwd().children():
+            if other in moving:
+                continue
+            head = landed.get(self._cellKey(other.position() + offset))
+            if head is None:
+                continue
+            run = [head]
+            key = self._cellKey(other.position() + offset)
+            cell = (key[0] - dkey[0], key[1] - dkey[1])
+            while (cell[0] - dkey[0], cell[1] - dkey[1]) in origins:
+                cell = (cell[0] - dkey[0], cell[1] - dkey[1])
+                run.append(origins[cell])
+            other.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]) - offset)
+            for node in run:
+                pair = self._linkedPair(node, other)
+                if pair is None:
+                    break
+                problem = self._swapChainPlaces(*pair)
+                if problem:
+                    self.hou_tab.flashMessage(None, problem, 2.0)
+                    break
+
     def translateSelectedNodes(self, direction, hierarchy=None):
         step = self._gridStep()
         delta = {
@@ -1137,25 +1182,20 @@ class HCNetworkEditor(HCPathTab):
         before_rect = self._selectedNodesRect()
         moved_any = False
         offset = self._nodeOffset()
-        # A lone node stepped onto another node's cell swaps with it, exactly
-        # as a mouse drop does. Several nodes moving together do not: which
-        # of them landed on what is ambiguous, the same rule as the drag.
-        swap_start = None
-        if len(final_nodes) == 1:
-            swap_start = next(iter(final_nodes)).position()
-        for hou_node in final_nodes:
-            pos = hou_node.position()
-            center = pos + offset
-            G = self._snapToGrid(center)
-
-            if not self._isOnGrid(pos):
-                hou_node.setPosition(G - offset)
-            else:
-                hou_node.setPosition(G + delta - offset)
-            moved_any = True
-        if swap_start is not None:
-            lone = next(iter(final_nodes))
-            self.swapDroppedNode(lone.path(), (swap_start[0], swap_start[1]))
+        origins = {}
+        with hou.undos.group("Move Nodes"):
+            for hou_node in final_nodes:
+                pos = hou_node.position()
+                center = pos + offset
+                G = self._snapToGrid(center)
+
+                if not self._isOnGrid(pos):
+                    hou_node.setPosition(G - offset)
+                else:
+                    origins[self._cellKey(G)] = hou_node
+                    hou_node.setPosition(G + delta - offset)
+                moved_any = True
+            self._displaceOverrun(final_nodes, origins, delta)
         if moved_any:
             after_rect = self._selectedNodesRect()
             if before_rect is None or after_rect is None:
diff --git a/tools/check.py b/tools/check.py
index 62caf58..0d79bf7 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -1085,7 +1085,7 @@ def check_node_ops():
     def keyboard_step_swaps_too():
         """translateSelectedNodes (the alt+hjkl moves): a lone selected node
         stepped onto a neighbour's cell swaps with it like a drop does; two
-        nodes stepping together only move."""
+        nodes stepping onto empty cells just move."""
         from hc import hcnetworkeditor as ne
 
         geo = hou.node("/obj").createNode("geo")
@@ -1140,7 +1140,7 @@ def check_node_ops():
             assert a.inputs() == (b,) and not b.inputs(), "linked pair did not trade chain places"
             assert c.position().isAlmostEqual(c_start), "a bystander moved"
 
-            # Two nodes stepping together: no swap, both just move.
+            # Two nodes stepping onto empty cells: both just move.
             a.setSelected(True, clear_all_selected=True)
             b.setSelected(True)
             a_pos, b_pos = a.position(), b.position()
@@ -1148,12 +1148,99 @@ def check_node_ops():
             assert a.position().isAlmostEqual(a_pos + hou.Vector2(step[0], 0.0))
             assert b.position().isAlmostEqual(b_pos + hou.Vector2(step[0], 0.0))
             assert c.position().isAlmostEqual(c_start), "a group move swapped with a bystander"
-            return "lone step onto a neighbour swaps cells and chain; a group step only moves"
+            return "lone step onto a neighbour swaps cells and chain; a group step onto empty cells only moves"
         finally:
             geo.destroy()
 
     check("keyboard step swaps too", keyboard_step_swaps_too)
 
+    def group_step_reorders_chain():
+        """A selection stepped onto a bystander sends it to the cell the
+        group freed and through the chain: P -> a -> b -> c -> Q with {a, b}
+        stepped down onto c gives P -> c -> a -> b -> Q, c in a's old cell.
+        Runs are per line: {a, b} stepped right onto c and d, one each in
+        their rows, swaps the pairs independently."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            P, a, b, c, Q = (geo.createNode("null", n) for n in ("P", "a", "b", "c", "Q"))
+            a.setInput(0, P); b.setInput(0, a); c.setInput(0, b); Q.setInput(0, c)
+
+            class FakePane:
+                def id(self):
+                    return 987661
+
+            class FakeTab:
+                def __init__(self):
+                    self.view = hou.BoundingRect(-20.0, -20.0, 20.0, 20.0)
+
+                def pane(self):
+                    return FakePane()
+
+                def setPref(self, name, value):
+                    pass
+
+                def pwd(self):
+                    return geo
+
+                def itemRect(self, node):
+                    pos = node.position()
+                    return hou.BoundingRect(pos[0], pos[1], pos[0] + 1.0, pos[1] + 0.3)
+
+                def visibleBounds(self):
+                    return hou.BoundingRect(self.view)
+
+                def setVisibleBounds(self, bounds, *args):
+                    self.view = hou.BoundingRect(bounds)
+
+                def flashMessage(self, *args):
+                    pass
+
+            editor = ne.HCNetworkEditor(FakeTab())
+            editor.updateCurrentNodeOverlay = lambda force=False: None
+            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, 4)), (a, (0, 3)), (b, (0, 2)), (c, (0, 1)), (Q, (0, 0))):
+                place(node, cell)
+            start = {n: n.position() for n in (P, a, b, c, Q)}
+            a.setSelected(True, clear_all_selected=True)
+            b.setSelected(True)
+            editor.translateSelectedNodes("down")
+            assert a.position().isAlmostEqual(start[b]) and b.position().isAlmostEqual(start[c]), "the group did not step"
+            assert c.position().isAlmostEqual(start[a]), f"c should take a's old cell, is at {c.position()}"
+            assert P.position().isAlmostEqual(start[P]) and Q.position().isAlmostEqual(start[Q]), "a bystander moved"
+            chain = [Q]
+            while chain[-1].inputs() and chain[-1].inputs()[0] is not None:
+                chain.append(chain[-1].inputs()[0])
+            assert [n.name() for n in chain] == ["Q", "b", "a", "c", "P"], [n.name() for n in chain]
+            assert not P.inputs() and P.outputs() == (c,), "P lost its output"
+
+            # Two rows, one bystander each: each run of one swaps with its own.
+            d = geo.createNode("null", "d")
+            for node, cell in ((a, (0, 1)), (b, (0, 0)), (c, (1, 1)), (d, (1, 0)), (P, (5, 5)), (Q, (5, 6))):
+                place(node, cell)
+            for n in (a, b, c, d, P, Q):
+                for i in range(len(n.inputs())):
+                    n.setInput(i, None)
+            c.setInput(0, a); d.setInput(0, b)
+            a.setSelected(True, clear_all_selected=True)
+            b.setSelected(True)
+            a_pos, b_pos, c_pos, d_pos = a.position(), b.position(), c.position(), d.position()
+            editor.translateSelectedNodes("right")
+            assert a.position().isAlmostEqual(c_pos) and c.position().isAlmostEqual(a_pos), "row 1 did not swap"
+            assert b.position().isAlmostEqual(d_pos) and d.position().isAlmostEqual(b_pos), "row 0 did not swap"
+            assert a.inputs() == (c,) and b.inputs() == (d,) and not c.inputs() and not d.inputs(), "rows did not trade chain places"
+            return "{a, b} onto c: c takes a's cell and the head of the chain; rows swap independently"
+        finally:
+            geo.destroy()
+
+    check("group step reorders the chain", group_step_reorders_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