SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
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."""