SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
scene viewer: Tab / Shift+Tab warp the pointer to the next / previous viewport
hou.GeometryViewport.size() reports every viewport at (0, 0), so where a
viewport sits comes from LAYOUT_CELLS, a split tree per layout over the
viewports() slots, read off screenshots of each layout; only widths and
heights are taken from the viewports, and splitter gaps are whatever the
tab has left over. viewportRects() lays that out in qtScreenGeometry()
space, and Next/Previous Viewport step from the cell under the pointer
(or the current viewport when the pointer is elsewhere) and warp through
hcviewregions.move_pointer. The unused layoutIndices() goes.
Tab and Shift+Tab were Houdini's operator_menu / operator_menu_branch in
the viewer; Backspace still opens the TAB menu. The menu symbols register
at startup, so the bindings take effect after a restart.
Co-Authored-By: Claude Opus 5 <[email protected]>
MainMenuCommon.xml | 20 ++++++
hc_hotkeys.json | 2 +
python3.13libs/hc/hcsceneviewer.py | 126 ++++++++++++++++++++++++-------------
3 files changed, 103 insertions(+), 45 deletions(-)
diff --git a/MainMenuCommon.xml b/MainMenuCommon.xml
index 9ed2bf0..d45e849 100755
--- a/MainMenuCommon.xml
+++ b/MainMenuCommon.xml
@@ -158,6 +158,26 @@ if tab is not None and tab.type() == 'HCSceneViewer':
]]></scriptCode>
</scriptItem>
+ <scriptItem id="h.pane.gview.hc_next_viewport">
+ <label>Next Viewport</label>
+ <scriptCode><![CDATA[
+from hc import HCSession
+tab = HCSession().currentTab()
+if tab is not None and tab.type() == 'HCSceneViewer':
+ tab.focusNextViewport()
+ ]]></scriptCode>
+ </scriptItem>
+
+ <scriptItem id="h.pane.gview.hc_prev_viewport">
+ <label>Previous Viewport</label>
+ <scriptCode><![CDATA[
+from hc import HCSession
+tab = HCSession().currentTab()
+if tab is not None and tab.type() == 'HCSceneViewer':
+ tab.focusPrevViewport()
+ ]]></scriptCode>
+ </scriptItem>
+
<scriptItem id="h.pane.gview.hc_toggle_point_numbers">
<label>Toggle Point Numbers</label>
<scriptCode><![CDATA[
diff --git a/hc_hotkeys.json b/hc_hotkeys.json
index d915b31..003f171 100644
--- a/hc_hotkeys.json
+++ b/hc_hotkeys.json
@@ -22,6 +22,8 @@
"h.pane.prevtab": "ctrl+shift+tab",
"h.pane.gview.refplane": "g",
"h.pane.gview.hc_toggle_group_list": "shift+g",
+ "h.pane.gview.hc_next_viewport": "tab",
+ "h.pane.gview.hc_prev_viewport": "shift+tab",
"h.pane.gview.hc_toggle_point_numbers": "ctrl+p",
"h.pane.gview.hc_toggle_prim_numbers": "shift+p",
"h.pane.gview.state.view.home_selection": "shift+s",
diff --git a/python3.13libs/hc/hcsceneviewer.py b/python3.13libs/hc/hcsceneviewer.py
index 5f4c158..c9c6431 100644
--- a/python3.13libs/hc/hcsceneviewer.py
+++ b/python3.13libs/hc/hcsceneviewer.py
@@ -13,39 +13,22 @@ def _hcPrintSceneViewerEvent(**kwargs):
extras_str = " ".join(f"{k}={v!r}" for k, v in extras.items())
print(f"[HCSceneViewer {viewer_name}] {event_type} {extras_str}".rstrip())
-"""
-Layout/viewport IDs
-
-DoubleSide: 2 3
-
-DoubleStack: 3
- 0
-
-Quad: 2 3
- 1 0
-
-QuadBottomSplit: 3
- 2 1 0
-
-QuadLeftSplit: 2
- 1 3
- 0
-
-TripleBottomSplit: 3
- 1 0
-
-TripleLeftSplit: 2
- 0 3
- 1
-
-Single:
-setViewportLayout(layout, single=-1)
--1: current viewport (viewportmouse is/was over)
-0: top-left quad viewport (default: Top)
-1: top-right quad viewport (default: Perspective)
-2: bottom-left quad viewport (default: Front)
-3: bottom-right quad viewport (default: Right)
-"""
+# How each hou.geometryViewportLayout tiles the tab, as a split tree over
+# hou.SceneViewer.viewports() slots (0 Right, 1 Front, 2 Top, 3 Perspective
+# by default; a layout shows slots, whatever type they have been changed
+# to). ('h', ...) lays its children out left to right, ('v', ...) top to
+# bottom; leaves read in the natural order. Read off screenshots, since
+# hou.GeometryViewport.size() reports every viewport at (0, 0).
+LAYOUT_CELLS = {
+ "Single": 3,
+ "DoubleSide": ("h", 2, 3),
+ "DoubleStack": ("v", 3, 0),
+ "Quad": ("v", ("h", 2, 3), ("h", 1, 0)),
+ "QuadBottomSplit": ("v", 3, ("h", 2, 1, 0)),
+ "QuadLeftSplit": ("h", ("v", 2, 1, 0), 3),
+ "TripleBottomSplit": ("v", 3, ("h", 1, 0)),
+ "TripleLeftSplit": ("h", ("v", 2, 1), 3),
+}
# hou.geometryViewportLayout names, in the order nextLayout() cycles them.
LAYOUT_NAMES = (
@@ -193,18 +176,6 @@ class HCSceneViewer(HCPathTab):
def layouts(self):
return tuple(getattr(hou.geometryViewportLayout, name) for name in LAYOUT_NAMES)
- def layoutIndices(self):
- return (
- (2, 3),
- (3, 0),
- (2, 3, 1, 0),
- (3, 2, 1, 0),
- (2, 1, 0, 3),
- (3, 1, 0),
- (2, 3, 1),
- (3,),
- )
-
def nextLayout(self):
layouts = self.layouts()
layout = self.layout()
@@ -371,6 +342,71 @@ class HCSceneViewer(HCPathTab):
viewports.append(HCViewport(viewport))
return viewports
+ def viewportRects(self):
+ """(hou.GeometryViewport, QRect) for each viewport the current layout
+ shows, in reading order, in the screen space of qtScreenGeometry().
+
+ Only a viewport's width and height are trusted; where a cell sits is
+ the layout's business (LAYOUT_CELLS), and the splitter gaps are
+ whatever the tab has left over, shared equally.
+ """
+ from PySide6.QtCore import QRect
+ slots = self.hou_tab.viewports()
+
+ def extent(node, axis):
+ while isinstance(node, tuple):
+ node = node[1]
+ return slots[node].size()[2 + axis]
+
+ def place(node, rect, out):
+ if not isinstance(node, tuple):
+ out.append((slots[node], rect))
+ return
+ axis = 0 if node[0] == "h" else 1
+ kids = node[1:]
+ sizes = [extent(kid, axis) for kid in kids]
+ total = rect.width() if axis == 0 else rect.height()
+ gap = max(total - sum(sizes), 0) / max(len(kids) - 1, 1)
+ pos = rect.x() if axis == 0 else rect.y()
+ for kid, size in zip(kids, sizes):
+ if axis == 0:
+ cell = QRect(round(pos), rect.y(), size, rect.height())
+ else:
+ cell = QRect(rect.x(), round(pos), rect.width(), size)
+ place(kid, cell, out)
+ pos += size + gap
+
+ out = []
+ place(LAYOUT_CELLS[self.layoutName()], self.hou_tab.qtScreenGeometry(), out)
+ return out
+
+ def _cycleViewport(self, step):
+ """Warp the pointer to the centre of the viewport ``step`` cells on
+ from the one under it (or the current one, when it is elsewhere)."""
+ from PySide6.QtGui import QCursor
+ from .hcviewregions import move_pointer
+ cells = self.viewportRects()
+ if not cells:
+ return None
+ pointer = QCursor.pos()
+ index = next((i for i, (_, rect) in enumerate(cells) if rect.contains(pointer)), None)
+ if index is None:
+ current = self.hou_tab.curViewport()
+ index = next((i for i, (vp, _) in enumerate(cells) if vp.name() == current.name()), 0)
+ # Not over the tab: land on that one rather than the one after.
+ step = 0 if step > 0 else step + 1
+ viewport, rect = cells[(index + step) % len(cells)]
+ move_pointer(rect.center())
+ return viewport
+
+ @command("Next Viewport")
+ def focusNextViewport(self):
+ return self._cycleViewport(1)
+
+ @command("Previous Viewport")
+ def focusPrevViewport(self):
+ return self._cycleViewport(-1)
+
@command("Frame")
def frameAllViewports(self):
for viewport in self.allViewports():