Zoom to Any Point or Prim in Houdini
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:
| Field | Value |
|---|---|
| Name | zoom_to_element |
| Label | Zoom to Element |
| Icon | TOOLS_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.