SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
neteditor: make grid snapping absolute and add a Snap to Grid command
Houdini's own Snap to Grid is magnetic: nodegraphsnap.snapGrid only pulls
an item onto a grid line when its centre is already within `snapradius`
(0.1 units) of one, so nodes dropped anywhere else stayed free. Push the
radius past half of any grid step and turn Snap to Visible Nodes off so it
cannot win instead; the nearest line then always captures drags, Tab-menu
placement, dots and box resizes. A grid_snap setting (default on) governs
it, and off restores Houdini's defaults rather than leaving the radius wide.
Snap to Grid in the HC Panel snaps the selection, or every node when
nothing is selected, for networks laid out before this and for the paths
that still bypass Houdini's snap: layoutChildren, paste, wire-insert shoves.
check.py drives the pref sync through a fake tab in both states and asserts
it does not rewrite prefs on unchanged events.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
python3.13libs/hc/hcnetworkeditor.py | 56 +++++++++++++++++++++++++++++++++++-
python3.13libs/hc/hcschema.py | 6 ++++
tools/check.py | 53 ++++++++++++++++++++++++++++++++++
3 files changed, 114 insertions(+), 1 deletion(-)
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index a0c3601..1877480 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -10,6 +10,8 @@ _cursors = hcstate.Store("hcnetcursor", hcstate.NETWORK)
_overlays = hcstate.Store("hcnetcursor_overlay", hcstate.NETWORK)
# Per-pane: the grid step last mirrored onto gridxstep/gridystep.
_grid_prefs = hcstate.Store("grid_pref_sync", hcstate.PANE)
+# Per-pane: the snapping prefs last written (see _syncSnapPrefs).
+_snap_prefs = hcstate.Store("snap_pref_sync", hcstate.PANE)
# Not per-tab at all: hcnetcursor_NxN.png presence, resolved once per path.
_cursor_asset_exists = {}
@@ -21,6 +23,17 @@ GRID_MODES = (
("Grid Lines", "2"),
)
+# Houdini's Snap to Grid is magnetic: nodegraphsnap.snapGrid only pulls an
+# item onto a grid line when its centre is already within `snapradius`
+# (0.1 network units by default) of one, so a node dropped anywhere else
+# stays free. With the radius wider than half of any grid step the nearest
+# line always captures, which is the hard snap the grid_snap setting asks
+# for. Snap to Visible Nodes competes for the same result, so it goes off
+# in that mode -- its alignment guides are redundant once everything is on
+# the grid anyway. The soft values are Houdini's own defaults.
+SNAP_PREFS_HARD = {"gridsnapping": "1", "dosnapping": "0", "snapradius": "1000"}
+SNAP_PREFS_SOFT = {"gridsnapping": "1", "dosnapping": "1", "snapradius": "0.1"}
+
class HCNetworkEditor(HCPathTab):
def __init__(self, hou_tab):
@@ -29,8 +42,11 @@ class HCNetworkEditor(HCPathTab):
def initialize(self):
self.setMenuOpen(0)
- # Show the grid by default in hc-spawned network editors.
+ # Show the grid by default in hc-spawned network editors. Houdini's
+ # grid snap is a no-op with the grid hidden, so this also underpins
+ # _syncSnapPrefs.
self.hou_tab.setPref('gridmode', '2')
+ self._syncSnapPrefs()
# Disable node previews in COP networks by default.
if self.childCat() == 'Cop':
@@ -194,8 +210,26 @@ class HCNetworkEditor(HCPathTab):
_grid_prefs.set(self.hou_tab, (x, y))
except hou.Error:
pass
+ self._syncSnapPrefs()
return hou.Vector2(x, y)
+ def _syncSnapPrefs(self):
+ """Write the snapping prefs the grid_snap setting asks for.
+
+ 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))
+ wanted = SNAP_PREFS_HARD if hard else SNAP_PREFS_SOFT
+ if _snap_prefs.get(self.hou_tab) == hard:
+ return
+ try:
+ for name, value in wanted.items():
+ self.hou_tab.setPref(name, value)
+ _snap_prefs.set(self.hou_tab, hard)
+ except hou.Error:
+ pass
+
def _nodeOffset(self):
"""Return node center offset (pos + offset = center)."""
ng = HCSettings().nodeGraph()
@@ -825,6 +859,26 @@ class HCNetworkEditor(HCPathTab):
# To put center at G, set pos to G - offset
node.setPosition(G - offset)
+ @command("Snap to Grid")
+ def snapNodesToGrid(self):
+ """Snap the selected nodes, or every node when none is selected.
+
+ Cleans up networks laid out before hard snapping, and anything that
+ still sets positions behind Houdini's snap code: layoutChildren,
+ paste, the shove-aside on wire insert.
+ """
+ pwd = self.hou_tab.pwd()
+ nodes = list(pwd.selectedChildren()) or list(pwd.children())
+ 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)
+
def _isOnGrid(self, pos, tol=1e-6):
# Check if the center (pos + offset) is on the grid
center = pos + self._nodeOffset()
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index 6f7201c..24642f3 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.",
),
+ "grid_snap": Setting(
+ "bool", True, label="Hard Grid Snap",
+ help="Every drag, Tab-menu placement and box resize lands on the "
+ "grid. Off restores Houdini's magnetic snap radius and its "
+ "node-to-node alignment guides.",
+ ),
"grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0)),
"grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0)),
"node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0)),
diff --git a/tools/check.py b/tools/check.py
index bca5834..39de528 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -684,6 +684,59 @@ def check_node_ops():
check("new-node grid snap", snap_lands_on_grid)
+ def snap_prefs_follow_the_setting():
+ """Houdini's Snap to Grid only captures within `snapradius`, so hard
+ snapping is that radius pushed past half a grid step, with node-to-node
+ snapping off so it cannot win instead. Off must hand back Houdini's
+ own defaults, not leave the wide radius behind."""
+ from hc import hcnetworkeditor as ne
+
+ setting = hcschema.lookup(("node_graph", "grid_snap"))
+ assert setting is not None and setting.kind == "bool", "grid_snap is not a bool setting"
+ assert setting.default is True, "grid_snap must default on"
+
+ class FakePane:
+ def id(self):
+ return 987654
+
+ class FakeTab:
+ def __init__(self):
+ self.prefs = {}
+
+ def pane(self):
+ return FakePane()
+
+ def setPref(self, name, value):
+ self.prefs[name] = value
+
+ max_half_step = max(setting.range[1] for setting in (
+ hcschema.lookup(("node_graph", "grid_x_step")),
+ hcschema.lookup(("node_graph", "grid_y_step")))) / 2.0
+ assert float(ne.SNAP_PREFS_HARD["snapradius"]) > max_half_step, \
+ "hard snap radius does not cover the widest grid step"
+ assert ne.SNAP_PREFS_HARD["dosnapping"] == "0", "node snapping must be off in hard mode"
+
+ original = HCSettings.nodeGraph
+ seen = {}
+ try:
+ for hard in (True, False):
+ HCSettings.nodeGraph = lambda _self, hard=hard: {"grid_snap": hard}
+ tab = FakeTab()
+ editor = ne.HCNetworkEditor(tab)
+ editor._syncSnapPrefs()
+ wanted = ne.SNAP_PREFS_HARD if hard else ne.SNAP_PREFS_SOFT
+ assert tab.prefs == wanted, f"grid_snap={hard}: wrote {tab.prefs}, wanted {wanted}"
+ # Same wanted set again must not write: this runs on every event.
+ tab.prefs.clear()
+ editor._syncSnapPrefs()
+ assert tab.prefs == {}, f"grid_snap={hard}: rewrote prefs with nothing changed"
+ seen[hard] = wanted["snapradius"]
+ finally:
+ HCSettings.nodeGraph = original
+ return f"radius {seen[True]} on, {seen[False]} off; no rewrite when unchanged"
+
+ check("snap prefs follow grid_snap", snap_prefs_follow_the_setting)
+
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."""