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

commit66412bfdfe7a44b9a2f85531022174f9f9db4fe0
parent151fe131ad
authorLucas Galante <[email protected]>
date2026-09-18 15:22
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():