Zoom to Any Point or Prim in Houdini

• 5 min read

When you’re debugging a mesh and the error log gives you a specific point or prim index, finding it in a 50,000-element mesh by spinning the camera is painful. This shelf tool teleports the viewport directly to any element by number.

What it does

  • Type a point or prim number — viewport jumps to it immediately
  • Live slider (0.1–20 units) — drag and the camera updates in real time
  • Reset Camera restores the view to exactly where it was before you opened the dialog
  • Non-modal — stays open while you work, switch numbers, keep inspecting
  • Works on Houdini 19.x and 20/21+

Setup

1. Right-click any shelf → New Tool

2. Options tab:

FieldValue
Namezoom_to_element
LabelZoom to Element
IconTOOLS_handles

3. Script tab — paste the script below

4. Click Accept — done.

Select a SOP geometry node, click the tool, and the dialog opens.

The script

import hou
try:
    from PySide2 import QtWidgets, QtCore
except ImportError:
    from PySide6 import QtWidgets, QtCore


class ZoomDialog(QtWidgets.QDialog):
    def __init__(self, geo, viewport, parent=None):
        super().__init__(parent)
        self._geo = geo
        self._viewport = viewport

        cam = viewport.defaultCamera()
        self._orig_translation = hou.Vector3(cam.translation())
        self._orig_pivot = hou.Vector3(cam.pivot())
        offset = self._orig_translation - self._orig_pivot
        self._cam_dir = offset.normalized() if offset.length() > 0.001 else hou.Vector3(0, 0, 1)

        self.setWindowTitle("Zoom to Point / Prim")
        self.setMinimumWidth(320)
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.Tool)

        layout = QtWidgets.QVBoxLayout(self)
        layout.setSpacing(10)

        type_group = QtWidgets.QGroupBox("Element Type")
        type_layout = QtWidgets.QHBoxLayout(type_group)
        self.point_radio = QtWidgets.QRadioButton("Point")
        self.prim_radio = QtWidgets.QRadioButton("Prim")
        self.point_radio.setChecked(True)
        type_layout.addWidget(self.point_radio)
        type_layout.addWidget(self.prim_radio)
        layout.addWidget(type_group)

        num_layout = QtWidgets.QHBoxLayout()
        num_layout.addWidget(QtWidgets.QLabel("Number:"))
        self.num_input = QtWidgets.QSpinBox()
        self.num_input.setRange(0, 9_999_999)
        self.num_input.setValue(0)
        num_layout.addWidget(self.num_input)
        layout.addLayout(num_layout)

        zoom_group = QtWidgets.QGroupBox("Zoom Distance")
        zoom_layout = QtWidgets.QVBoxLayout(zoom_group)
        self.zoom_label = QtWidgets.QLabel("0.1")
        self.zoom_label.setAlignment(QtCore.Qt.AlignCenter)
        self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.slider.setRange(1, 200)
        self.slider.setValue(1)
        self.slider.setTickInterval(10)
        self.slider.setTickPosition(QtWidgets.QSlider.TicksBelow)
        zoom_layout.addWidget(self.zoom_label)
        zoom_layout.addWidget(self.slider)
        tick_labels = QtWidgets.QHBoxLayout()
        tick_labels.addWidget(QtWidgets.QLabel("0.1"))
        tick_labels.addStretch()
        tick_labels.addWidget(QtWidgets.QLabel("10"))
        tick_labels.addStretch()
        tick_labels.addWidget(QtWidgets.QLabel("20"))
        zoom_layout.addLayout(tick_labels)
        layout.addWidget(zoom_group)

        btn_layout = QtWidgets.QHBoxLayout()
        reset_btn = QtWidgets.QPushButton("Reset Camera")
        close_btn = QtWidgets.QPushButton("Close")
        close_btn.setDefault(True)
        reset_btn.clicked.connect(self._reset_camera)
        close_btn.clicked.connect(self.close)
        btn_layout.addWidget(reset_btn)
        btn_layout.addWidget(close_btn)
        layout.addLayout(btn_layout)

        self.slider.valueChanged.connect(self._on_slider)
        self.num_input.valueChanged.connect(self._apply_zoom)
        self.point_radio.toggled.connect(self._apply_zoom)
        self._apply_zoom()

    def _on_slider(self, value):
        self.zoom_label.setText(f"{value / 10.0:.1f}")
        self._apply_zoom()

    def _resolve_pos(self):
        elem_num = self.num_input.value()
        if self.point_radio.isChecked():
            if elem_num >= self._geo.intrinsicValue("pointcount"):
                return None
            return hou.Vector3(self._geo.point(elem_num).position())
        else:
            if elem_num >= self._geo.intrinsicValue("primitivecount"):
                return None
            prim = self._geo.prim(elem_num)
            verts = list(prim.vertices())
            pts = [hou.Vector3(v.point().position()) for v in verts]
            return sum(pts, hou.Vector3()) / len(pts)

    def _apply_zoom(self):
        pos = self._resolve_pos()
        if pos is None:
            return
        zoom_dist = self.slider.value() / 10.0
        cam = self._viewport.defaultCamera()
        cam.setPivot(pos)
        cam.setTranslation(pos + self._cam_dir * zoom_dist)
        self._viewport.setDefaultCamera(cam)

    def _reset_camera(self):
        cam = self._viewport.defaultCamera()
        cam.setPivot(self._orig_pivot)
        cam.setTranslation(self._orig_translation)
        self._viewport.setDefaultCamera(cam)


def zoom_to_element():
    node = hou.selectedNodes()
    node = node[0] if node else None
    if node is None or not isinstance(node, hou.SopNode):
        hou.ui.displayMessage("Select a geometry node first.", severity=hou.severityType.Error)
        return
    desktop = hou.ui.curDesktop()
    viewer = desktop.paneTabOfType(hou.paneTabType.SceneViewer)
    if viewer is None:
        hou.ui.displayMessage("No Scene Viewer found.", severity=hou.severityType.Warning)
        return
    dialog = ZoomDialog(
        geo=node.geometry(),
        viewport=viewer.curViewport(),
        parent=hou.qt.mainWindow(),
    )
    hou.session._zoom_dialog = dialog
    dialog.show()


zoom_to_element()

Technical notes

A few things that aren’t obvious from the Houdini docs:

cam.translation() returns a plain tuple, not a Vector3. Wrap it before doing math: hou.Vector3(cam.translation()) — otherwise you get TypeError: unsupported operand type(s) for -: 'tuple' and 'tuple'.

hou.ui has no slider widget. For any UI beyond text fields and buttons you need PySide directly. hou.qt.mainWindow() gives you the correct parent so the dialog floats over Houdini properly.

dialog.exec_() is modal — the window closes when it returns. dialog.show() makes it non-blocking so the dialog stays open while you interact with the viewport.

Python GC kills the dialog immediately after the function returns. Parking the reference on hou.session._zoom_dialog keeps it alive for the whole session.

PySide2 vs PySide6. Houdini 19.x ships PySide2, 20+ ships PySide6. The try/except ImportError at the top handles both automatically.