SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: publish view-pane rectangles to the compositor
cce-fx emulates the view tool for a two-finger swipe over Houdini, since
a trackpad gesture never survives Xwayland, and it did so over the whole
window: the parameter editor only scrolled with Ctrl held. The
compositor can now confine that drag to rectangles the app names
(ccectl touchpad-view-regions), so hc.hcviewregions polls the pane
layout four times a second and publishes every visible SceneViewer and
NetworkEditor tab, per top-level window by X11 id, in window-local
pixels. Started from uiready.py, restarted by reloadHC, inert without
ccectl on PATH.
Verified in a cce-shadow session: with hou-control loaded, a finger
scroll over the parameter pane scrolls it, over the viewport and network
editor it still tumbles and pans.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 1 +
python3.13libs/hc/hcsession.py | 6 ++
python3.13libs/hc/hcviewregions.py | 122 +++++++++++++++++++++++++++++++++++++
python3.13libs/uiready.py | 10 +++
4 files changed, 139 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index e88e502..4837523 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,7 @@ HC settings cannot live in Houdini's own **Edit > Preferences** window. That win
These are recognized by Houdini's startup/event system by filename convention:
- `uiready.py` — runs once when the UI is ready; instantiates `HCSession` and calls `reloadHotkeys` + `toggleStowbars`.
+- `hc/hcviewregions.py` — publishes the window-local rectangles of every SceneViewer and NetworkEditor tab to the user's compositor (`ccectl touchpad-view-regions x11:<id> ...`). `cce-fx` turns a two-finger swipe over Houdini into an emulated view drag (Space + button), because a trackpad gesture never survives Xwayland; the regions confine that drag to the view panes so the parameter editor and every other pane scroll normally. Started from `uiready.py`, restarted by `reloadHC()`, and inert without `ccectl` on `PATH`. Verified headlessly in a `cce-shadow` session by injecting `ccectl pointer-scroll ... finger` over each pane and diffing screenshots.
- `nodegraphhooks.py` — Houdini's network-editor event hook. Implements `createEventHandler(uievent, pending_actions)` and dispatches `KeyboardEvent`s through a local `keymap` dict that calls into `HCNetworkEditor`. Return `(None, True)` to consume the event, `(None, False)` to let Houdini handle it.
### Scripts (`scripts/`)
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 6377756..048b23d 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -485,6 +485,10 @@ class HCSession:
w.hide()
w.setParent(None)
w.deleteLater()
+ # The view-regions poll is a callback from the module about to be
+ # dropped; take it out first and start a fresh one below.
+ from .hcviewregions import stop as _stop_view_regions
+ _stop_view_regions()
# Drop every hc.* module so the next import reloads from disk
removed = [m for m in list(sys.modules) if m == "hc" or m.startswith("hc.")]
for m in removed:
@@ -494,6 +498,8 @@ class HCSession:
# Restore the status bar overlay (destroyed above)
from .hcstatusbar import HCStatusBar
HCStatusBar().show()
+ from .hcviewregions import HCViewRegions
+ HCViewRegions().start()
# An open HC Settings tab holds a widget built from the class object of
# the module we just dropped, so it would keep running pre-reload code.
# reloadActiveInterface() re-runs the pypanel's onCreateInterface()
diff --git a/python3.13libs/hc/hcviewregions.py b/python3.13libs/hc/hcviewregions.py
new file mode 100644
index 0000000..3f0f421
--- /dev/null
+++ b/python3.13libs/hc/hcviewregions.py
@@ -0,0 +1,122 @@
+"""Tell the compositor where Houdini's view panes are.
+
+Under Xwayland a two-finger swipe never reaches Houdini as a trackpad
+gesture (Qt's X11 backend leaves the pixel deltas empty), so ``cce-fx``
+emulates the view tool instead: for every app in its ``touchpad_view_apps``
+it turns a swipe into a Space + button drag on the window. That is right
+over a 3D viewport or a network editor and wrong everywhere else -- the
+parameter editor, the geometry spreadsheet and every other pane want the
+swipe as the plain scroll it was, and got nothing at all.
+
+So this module publishes the rectangles of the panes that want the drag,
+per top-level window, through ``ccectl touchpad-view-regions``. The
+compositor confines the drag to them and passes a swipe anywhere else
+through untouched. Rectangles are window-local pixels as Houdini measures
+them, which for an X11 window under ``xwayland_hidpi`` are exactly the
+compositor's surface coordinates; the window is named by its X11 id, the
+one identifier the two sides share.
+
+The poll runs on Houdini's event loop and only talks to the compositor
+when a rectangle changed, so its steady-state cost is a few HOM calls
+every quarter second. Without ``ccectl`` on ``PATH`` (or a compositor
+that does not know the command) it is inert: every publish is
+fire-and-forget and errors are dropped.
+"""
+import shutil
+import subprocess
+import time
+
+import hou
+
+#: Pane tab types that want the emulated view drag. Everything else gets
+#: the swipe as a scroll.
+VIEW_TAB_TYPES = ("SceneViewer", "NetworkEditor")
+#: Seconds between polls of the pane layout.
+POLL_INTERVAL = 0.25
+#: Where the running callback is kept, so a reload can find and remove the
+#: one installed by the module it is replacing (see ``HCSession.reloadHC``).
+_SESSION_ATTR = "_hc_view_regions_callback"
+
+
+def ccectl():
+ """Path of the compositor control client, or None when there is none."""
+ return shutil.which("ccectl")
+
+
+def stop():
+ """Remove the poll installed by any earlier ``HCViewRegions.start``."""
+ cb = getattr(hou.session, _SESSION_ATTR, None)
+ if cb is None:
+ return
+ try:
+ hou.ui.removeEventLoopCallback(cb)
+ except Exception:
+ pass
+ setattr(hou.session, _SESSION_ATTR, None)
+
+
+class HCViewRegions:
+ """Publishes the view-pane rectangles of every Houdini window."""
+
+ def __init__(self):
+ self._exe = ccectl()
+ self._last = {}
+ self._next = 0.0
+
+ def start(self):
+ """Install the poll; a no-op without ``ccectl``. Returns whether it ran."""
+ stop()
+ if not self._exe:
+ return False
+ self._last = {}
+ hou.ui.addEventLoopCallback(self._tick)
+ setattr(hou.session, _SESSION_ATTR, self._tick)
+ return True
+
+ def _tick(self):
+ now = time.monotonic()
+ if now < self._next:
+ return
+ self._next = now + POLL_INTERVAL
+ try:
+ regions = self.collect()
+ except Exception:
+ # A pane mid-teardown can raise from any of the calls below; the
+ # next tick sees a settled layout.
+ return
+ for win_id, rects in regions.items():
+ if self._last.get(win_id) != rects:
+ self.publish(win_id, rects)
+ for win_id in set(self._last) - set(regions):
+ self.publish(win_id, [])
+ self._last = regions
+
+ def collect(self):
+ """``{x11 window id: [(x, y, w, h), ...]}`` for every window with a view pane.
+
+ Rectangles are in the window's own pixels. A window whose view panes
+ are all hidden gets an empty list, which the compositor treats as
+ "no drag anywhere" rather than "whole window".
+ """
+ out = {}
+ for tab in hou.ui.paneTabs():
+ if tab.type().name() not in VIEW_TAB_TYPES or not tab.isCurrentTab():
+ continue
+ panel = tab.pane().floatingPanel()
+ widget = hou.qt.floatingPanelWindow(panel) if panel else hou.qt.mainWindow()
+ widget = widget.window()
+ if not widget.isVisible():
+ continue
+ g = tab.qtScreenGeometry()
+ origin = widget.geometry().topLeft()
+ out.setdefault(int(widget.winId()), []).append(
+ (g.x() - origin.x(), g.y() - origin.y(), g.width(), g.height()))
+ return {k: sorted(v) for k, v in out.items()}
+
+ def publish(self, win_id, rects):
+ args = [self._exe, "touchpad-view-regions", f"x11:{win_id}"]
+ args += [",".join(str(int(v)) for v in r) for r in rects] or ["clear"]
+ try:
+ subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except OSError:
+ pass
diff --git a/python3.13libs/uiready.py b/python3.13libs/uiready.py
index b89bb2d..508cc06 100644
--- a/python3.13libs/uiready.py
+++ b/python3.13libs/uiready.py
@@ -50,6 +50,16 @@ _step("reloadHotkeys", hc_session.reloadHotkeys)
_step("initializeNetworkEditorsDeferred", hc_session.initializeNetworkEditorsDeferred)
_step("updateNodeColors", hc_session.updateNodeColors)
+
+def _startViewRegions():
+ from hc.hcviewregions import HCViewRegions
+ HCViewRegions().start()
+
+
+# Tells cce-fx which panes want a two-finger swipe as a view drag, so the
+# others scroll; see hc.hcviewregions. Inert without ccectl.
+_step("viewRegions", _startViewRegions)
+
desktop_mode = HCSettings().desktopMode()
if desktop_mode == "detached":