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

commit22bc0e5862b265c6e4c9c4953b3372c4dfd6a34c
parentf6e2bfea53
authorLucas Galante <[email protected]>
date2026-09-14 13:43
neteditor: sweep the network onto the grid after every action

Houdini's snap code never sees layoutChildren, paste or the shove-aside on
wire insert, so nodes placed by those stayed off grid. The hook now queues
a pending action on each mousedown and key hit it sees; Houdini runs
pending actions after its own handler, so a key hit's sweep completes
inside the same event and a mouse action's on the mouseup the move handler
consumes. The sweep snaps every off-grid node in the displayed network with
undo disabled, so Ctrl+Z undoes the layout or move rather than first
unsnapping its result. gridSnapEnabled gates both it and the pref sync.

reloadHC now reloads nodegraphhooks too: it is Houdini's module, not hc's,
but it imports HCNetworkEditor, so a reload left every editor event
dispatching into the class object just dropped.

check.py knocks two of three nodes off grid in a scratch network and
asserts the sweep moves exactly those, none on a second pass, and nothing
with grid_snap off.

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

 python3.13libs/hc/hcnetworkeditor.py | 27 +++++++++++++++++---
 python3.13libs/hc/hcsession.py       |  7 ++++++
 python3.13libs/hc/hcsettings.py      |  4 +++
 python3.13libs/nodegraphhooks.py     | 39 ++++++++++++++++++++++++++++
 tools/check.py                       | 49 ++++++++++++++++++++++++++++++++++++
 5 files changed, 122 insertions(+), 4 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 1877480..53384ea 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -219,7 +219,7 @@ class HCNetworkEditor(HCPathTab):
         Runs from _gridStep on every network editor event, so it only touches
         the prefs when the wanted set differs from what was last written.
         """
-        hard = bool(HCSettings().nodeGraph().get("grid_snap", True))
+        hard = HCSettings().gridSnapEnabled()
         wanted = SNAP_PREFS_HARD if hard else SNAP_PREFS_SOFT
         if _snap_prefs.get(self.hou_tab) == hard:
             return
@@ -869,15 +869,34 @@ class HCNetworkEditor(HCPathTab):
         """
         pwd = self.hou_tab.pwd()
         nodes = list(pwd.selectedChildren()) or list(pwd.children())
+        with hou.undos.group("Snap to Grid"):
+            moved = self._snapNodes(nodes)
+        self.updateCurrentNodeOverlay()
+        self.hou_tab.flashMessage(
+            None, f"Snapped {moved} of {len(nodes)} nodes", 1.0)
+
+    def sweepToGrid(self):
+        """Snap every off-grid node in the displayed network; return the count.
+
+        nodegraphhooks queues this after each mouse action and key hit, for
+        the paths Houdini's snap code never sees: layoutChildren, paste, the
+        shove-aside on wire insert. Undo is off for it, so Ctrl+Z undoes the
+        layout or move itself rather than first unsnapping its result -- the
+        positions it restores were swept already.
+        """
+        if not HCSettings().gridSnapEnabled():
+            return 0
+        with hou.undos.disabler():
+            return self._snapNodes(self.hou_tab.pwd().children())
+
+    def _snapNodes(self, nodes):
         moved = 0
         for node in nodes:
             if self._isOnGrid(node.position()):
                 continue
             self.snapToGrid(node)
             moved += 1
-        self.updateCurrentNodeOverlay()
-        self.hou_tab.flashMessage(
-            None, f"Snapped {moved} of {len(nodes)} nodes", 1.0)
+        return moved
 
     def _isOnGrid(self, pos, tol=1e-6):
         # Check if the center (pos + offset) is on the grid
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 048b23d..aac8024 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -494,6 +494,13 @@ class HCSession:
         for m in removed:
             del sys.modules[m]
         import hc
+        # nodegraphhooks is Houdini's module, not hc's, but it holds
+        # `from hc import HCNetworkEditor` -- left alone it keeps dispatching
+        # every network editor event into the class object just dropped.
+        hooks = sys.modules.get("nodegraphhooks")
+        if hooks is not None:
+            import importlib
+            importlib.reload(hooks)
         hou.ui.setStatusMessage(f"Reloaded hc ({len(removed)} modules)")
         # Restore the status bar overlay (destroyed above)
         from .hcstatusbar import HCStatusBar
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 6d9cd78..c5fab61 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 gridSnapEnabled(self):
+        """Hard grid snapping: the wide snap radius and the post-action sweep."""
+        return bool(self.nodeGraph().get("grid_snap", True))
+
     def nodeColoringEnabled(self):
         """Whether HC colors nodes at all.
 
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index 50e5449..1927191 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -36,6 +36,41 @@ class _PendingSelectionSyncAction(base.PendingDelayedAction):
         _syncSelection(HCNetworkEditor(self.editor), self.editor)
 
 
+class _PendingSnapAction(base.PendingAction):
+    """Sweep the network onto the grid once the action a hook-seen event
+    started has finished.
+
+    The hook only sees an event when no handler is active, and the pending
+    actions run after the handler on every later event. A key hit is handled
+    to completion inside the same event, so a keyboard sweep completes on the
+    first call, after layoutChildren or paste has placed its nodes. A mouse
+    action lasts until mouseup, which Houdini's move handler consumes -- but
+    the pending action still sees it, after applyAdjustments has written the
+    positions.
+    """
+
+    def __init__(self, editor, from_keyboard):
+        base.PendingAction.__init__(self)
+        self.editor = editor
+        self.from_keyboard = from_keyboard
+
+    def completeAction(self, uievent):
+        done = self.from_keyboard or (
+            isinstance(uievent, MouseEvent) and uievent.eventtype == 'mouseup')
+        if done:
+            HCNetworkEditor(self.editor).sweepToGrid()
+        return done
+
+
+def _queueSnapSweep(editor, pending_actions, from_keyboard):
+    if not HCSettings().gridSnapEnabled():
+        return
+    for action in pending_actions:
+        if isinstance(action, _PendingSnapAction) and action.editor == editor:
+            return
+    pending_actions.append(_PendingSnapAction(editor, from_keyboard))
+
+
 def _queueSelectionSync(editor, pending_actions):
     for action in pending_actions:
         if isinstance(action, _PendingSelectionSyncAction) and action.editor == editor:
@@ -69,6 +104,8 @@ def createEventHandler(uievent, pending_actions):
             hc_editor.updateCurrentNodeOverlay()
 
     if isinstance(uievent, MouseEvent):
+        if editor is not None and uievent.eventtype == 'mousedown':
+            _queueSnapSweep(editor, pending_actions, from_keyboard=False)
         # 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
@@ -91,6 +128,8 @@ def createEventHandler(uievent, pending_actions):
         key = str(getattr(uievent, "key", "")).lower()
         rawkey = str(getattr(uievent, "rawkey", "")).lower()
         eventtype = str(getattr(uievent, "eventtype", "")).lower()
+        if editor is not None and eventtype == 'keyhit':
+            _queueSnapSweep(editor, pending_actions, from_keyboard=True)
         if (
             editor is not None
             and (key == "f" or rawkey == "f")
diff --git a/tools/check.py b/tools/check.py
index 39de528..ca07a5d 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -737,6 +737,55 @@ def check_node_ops():
 
     check("snap prefs follow grid_snap", snap_prefs_follow_the_setting)
 
+    def sweep_snaps_the_network():
+        """The post-action sweep nodegraphhooks queues: every off-grid node in
+        the displayed network lands on the grid, nodes already there are left
+        alone, and it is gated on grid_snap."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            nodes = [geo.createNode("null") for _ in range(3)]
+            offsets = (hou.Vector2(0.37, 0.21), hou.Vector2(-1.13, 0.48), hou.Vector2(0, 0))
+
+            class FakePane:
+                def id(self):
+                    return 987655
+
+            class FakeTab:
+                def pane(self):
+                    return FakePane()
+
+                def setPref(self, name, value):
+                    pass
+
+                def pwd(self):
+                    return geo
+
+            editor = ne.HCNetworkEditor(FakeTab())
+            for node, offset in zip(nodes, offsets):
+                editor.snapToGrid(node)
+                node.setPosition(node.position() + offset)
+            off_before = sum(not editor._isOnGrid(n.position()) for n in nodes)
+            assert off_before == 2, f"expected 2 nodes off grid to start, got {off_before}"
+
+            original = HCSettings.gridSnapEnabled
+            try:
+                HCSettings.gridSnapEnabled = lambda _self: False
+                assert editor.sweepToGrid() == 0, "sweep ran with grid_snap off"
+                HCSettings.gridSnapEnabled = lambda _self: True
+                moved = editor.sweepToGrid()
+            finally:
+                HCSettings.gridSnapEnabled = original
+            assert moved == 2, f"sweep moved {moved} nodes, wanted 2"
+            assert all(editor._isOnGrid(n.position()) for n in nodes), "a node is still off grid"
+            assert editor.sweepToGrid() == 0, "a second sweep still moved nodes"
+            return "2 of 3 snapped, none on the second pass, nothing with grid_snap off"
+        finally:
+            geo.destroy()
+
+    check("post-action sweep", sweep_snaps_the_network)
+
     def pwd_is_a_real_node():
         """HCPathTab.pwd() returned an HCNode with no path(), so path() -- and
         the Show Path Message command through it -- raised AttributeError."""