Houdini 17.5 Python Scripting

Python state user interface events

How to listen for and respond to direct UI input.

On this page

Python viewer states

Overview

(See Python states for the basics of how to implement a custom viewer state.)

The standard way to allow user interaction with a node is to bind handles to node parameters in a node’s state. Handles can be very powerful on their own, letting you specify a ready-made user interface for a wide variety of parameter setups.

However, sometimes you want the ability to interpret lower-level user input such as mouse moves, button presses, mouse wheel clicks, tablet tilt and pressure, and so on. You can implement the onMouseEvent() callback

  • While your state is active, in your mouse event handler you can get a ray (a directional line) from the mouse "into" the screen. You can intersect this pointing ray with the geometry to know what is under the mouse pointer at that moment.

  • To set up a right-click context menu for your custom state, see Python state menu.

  • The UI device object has useful methods for detecting modifier keys and arrow keys. For information on capturing hotkeys, see Python state menu hotkeys

Input events

The Python state implementation supports the following callback methods:

Method

Called for

onKeyEvent

keyboard events

See reading the keyboard device below.

onMouseEvent

input device moves/clicks

See reading the UI device below.

onMouseWheelEvent

mouse wheel scroll

See scroll wheel below.

The UIEvent object you get from the "ui_event" key of the dictionary passed to these callbacks has two useful methods: hou.UIEvent.device() returns a hou.UIEventDevice object that lets you read the state of the user input device. hou.UIEvent.ray returns a pointing ray into the 3D scene.

Reading the UI input device

The hou.UIEventDevice returned by kwargs["ui_event"].device() in a UI event handler contains methods for getting the screen coordinates of the mouse within the view, the state of the mouse buttons and modifier keys, and also tablet-specific data.

See the help for hou.UIEventDevice to see what data is available.

from __future__ import print_function


class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer

    def onMouseWheelEvent(self, kwargs):
        # Get the UI envent
        ui_event = kwargs["ui_event"]
        # Get the UI device object
        device = ui_event.device()

        print("Screen X=", device.mouseX())
        print("Screen Y=", device.mouseY())
        print("LMB pressed=", device.isLeftButton())
        print("MMB pressed=", device.isMiddleButton())
        print("RMB pressed=", device.isRightButton())
        print("Shift pressed=", device.isShiftKey())
        print("Ctrl pressed=", device.isCtrlKey())
        print("Alt/option pressed=", device.isAltKey())

Left mouse button

Instead of storing whether the left mouse button was pressed (using device.isLeftButton()) in the last event and comparing it to the current event to see if the mouse was pressed or dragged or released, you can call hou.UIEvent.reason() and check the hou.uiEventReason value.

from __future__ import print_function


def onMouseEvent(self, kwargs):
    ui_event = kwargs["ui_event"]
    reason = ui_event.reason()
    if reason == hou.uiEventReason.Picked:
        print("LMB click")

    elif reason == hou.uiEventReason.Start:
        print("LMB was pressed down")

    elif reason == hou.uiEventReason.Active:
        print("Mouse dragged with LMB down")

    elif reason == hou.uiEventReason.Changed:
        print("LMB was released")

Other than mouse clicks/drags, the usual reason returned will be hou.uiEventReason.Located for a mouse move.

Mouse wheel

If the user scrolls the mouse wheel vertically, Houdini calls your state’s onMouseWheelEvent() method (if it exists). You can then read the scroll value from the device using hou.UIEventDevice.mouseWheel().

This API does not currently support scoll axes other than vertical.

from __future__ import print_function


class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer

    def onMouseWheelEvent(self, kwargs):
        # Get the UI device object
        device = kwargs["ui_event"].device()
        scroll = device.mouseWheel()
        print("Scroll=", scroll)

See the help for hou.UIEventDevice.mouseWheel() for information about the number returned.

Reading the keyboard device

The onKeyEvent handler let you process keyboard events such as single and modifier keys. The hou.UIEventDevice returned by kwargs["ui_event"].device() gives access to the key being pressed and other useful information about the event.

def onKeyEvent(self, kwargs):
    ui_event = kwargs['ui_event']

    # Log some key event info in the Viewer State Browser console
    self.trace( ui_event.device().keyString(), name='key string' )
    self.trace( ui_event.device().keyValue(), name='key value' )
    self.trace( ui_event.device().isAutoRepeat(), name='isAutoRepeat' )

    # Use the key string to decide if the event should be consumed or not.
    # Store the pressed key for use in other handlers.
    self.key_pressed = ui_event.device().keyString()
    if self.key_pressed in ('a', 'shift a', 'ctrl g'):
        # returns True to consume the event
        return True

    # Consume the event only if the 'f', 'p' or 'v' key is held
    if ui_event.device().isAutoRepeat():            
        ascii_key = ui_event.device().keyValue()
        if ascii_key in (102, 112, 118):
            self.key_pressed = ui_event.device().keyString()
            return True

    self.key_pressed = None

    # return False if the event is not consumed
    return False

Tablet

The hou.UIEventDevice returned by kwargs["ui_event"].device() in a UI event handler lets you check whether the user is using a tablet, and read tablet-specific intput data.

See the help for hou.UIEventDevice to see what tablet-specific data is available.

from __future__ import print_function


class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer

    def onMouseWheelEvent(self, kwargs):
        # Get the UI envent
        ui_event = kwargs["ui_event"]
        # Get the UI device object
        device = ui_event.device()

        print("Screen X=", device.mouseX())
        print("Screen Y=", device.mouseY())
        print("LMB pressed=", device.isLeftButton())
        print("MMB pressed=", device.isMiddleButton())
        print("RMB pressed=", device.isRightButton())
        print("Shift pressed=", device.isShiftKey())
        print("Ctrl pressed=", device.isCtrlKey())
        print("Alt/option pressed=", device.isAltKey())

        if device.isTablet():
            print("Angle=", device.tabletAngle())
            print("Pressure=", device.tabletPressure())
            print("Roll=", device.tabletRoll())
            print("Tilt=", device.tabletTilt())

Getting the pointing ray

  • In the onMouseEvent handler, you can get a reference to the hou.ViewerEvent object using kwargs["ui_event"], and then call hou.ViewerEvent.ray() to get a world-space origin and direction vector representing a "pointing ray" firing from the mouse pointer "into" the scene. You can use this to tell what’s under the mouse pointer in the 3D scene.

    class MyState(object):
        def __init__(self, state_name, scene_viewer):
            self.state_name = state_name
            self.scene_viewer = scene_viewer
    
        def onMouseEvent(self, kwargs):
            ui_event = kwargs["ui_event"]
            origin, direction = ui_event.ray()
    
  • If you want the pointing ray to take the current snapping controls into account, use hou.ViewerEvent.snappingRay() instead of ray(). This method returns a tuple of (ray_origin, ray_direction, snapped), where the third item is a boolean indicating whether the ray was snapped.

Intersecting geometry

You can check what geometry is under the pointer using hou.Geometry.intersect(). This method is a bit unusual in that it requires setting up C-style "output arguments". You can make it slightly easier to use by wrapping it in a utility function:

# In viewerstate.utils
def sopGeometryIntersection(geometry, ray_origin, ray_dir):
    # Make objects for the intersect() method to modify
    position = hou.Vector3()
    normal = hou.Vector3()
    uvw = hou.Vector3()
    # Try intersecting the ray with the geometry
    intersected = geometry.intersect(
        ray_origin, ray_dir, position, normal, uvw
    )
    # Returns a tuple of four values:
    # - the primitive number of the primitive hit, or -1 if the ray didn't hit
    # - the 3D position of the intersection point (as Vector3)
    # - the normal of the ray to the hit primitive (as Vector3)
    # - the uvw coordinates of the intersection on the primitive (as Vector3)
    return intersected, position, normal, uvw

This function takes a hou.Geometry object, the ray origin, and the ray direction. You get the ray origin and direction from kwargs["ui_event"].ray() (see above). For the geometry, you should generally use the current node’s input geometry.

Tip

You should get a single reference to the geometry and hold onto it rather than getting the geometry separately in each onMouseEvent().

The reason is Houdini builds acceleration structures to make intersection faster when you call Geometry.intersect(). If you keep one reference to the geometry, you only pay this cost once, whereas if you get a new Geometry reference in each event, you have to pay this cost over and over, leading to slow performance.

class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer
        self._geometry = None

    def onEnter(self, kwargs):
        node = kwargs["node"]
        if inputs and inputs[0]:
            self._geometry = inputs[0].geometry()

    def onMouseEvent(self, kwargs):
        node = kwargs["node"]
        ui_event = kwargs["ui_event"]
        ray_origin, ray_dir = ui_event.ray()

        if self._geometry:
            hit, pos, norm, uvw = sopGeometryIntersection(
                self._geometry, ray_origin, ray_dir
            )
            # ...

Depending on the type of state you create, you might want to intersect a different geometry. For example, in an "inspector" state (not tied to a node), you might want to intersect the display geometry:

from stateutils import ancestorObject

network = ancestorObject(scene_viewer.pwd())
geometry = network.displayNode().geometry()

The sopGeometryIntersection function returns a tuple of four items:

Type

Content

int

An integer representing the primitive number of the primitive hit by the ray. If the ray missed the geometry, this is -1.

hou.Vector3

The 3D coordinates of the intersection point.

hou.Vector3

The direction of the ray relative to the surface of the hit primitive at the intersection point.

hou.Vector3

The U, V (and W) coordinates of the intersection point across the surface of the hit primitive.

(If the ray missed, the three vectors are left at their defaults: 0, 0, 0.)

See flexible intersection below for how to combine geometry intersection with construction plane intersection.

Interacting with the intersected primitive

  • If the first number returned by sopGeometryIntersection() is not -1, it is a primitive number. You can get a hou.Prim object for that primitive using hou.Geometry.prim().

  • The object you get will often be a more specialized subclass of Prim. For example, if you ask for a polygon primitive, you will get a hou.Polygon object.

    The best way to check what kind of primitive you have is to call hou.Prim.type() and check what kind of hou.primType value it returns.

    prim = geometry.prim(prim_num)
    if prim.type() != hou.primType.Polygon:
        raise hou.Error("This tool only works with polygons")
    
  • The hou.Prim/hou.Polygon object has lots of useful methods for inspecting the geometry. (Remember that the geometry you get from the scene is read-only.)

    For example:

  • Note that some primitive, point, or vertex related methods might be on the hou.Geometry object rather than on hou.Prim, hou.Point, or hou.Vertex.

    For example, if you want a list of all current primitive attributes, that information is actually on the Geometry object (hou.Geometry.primAttribs()).

Intersecting a plane

The hou.hmath.intersectPlane() function lets you find the intersection position between a ray and an arbitrary plane.

The intersectPlane function expects the plane in the form of an origin and a normal vector. Often you will want to project the pointing ray onto the construction plane or reference plane. You can get the position and orientation of the construction or reference plane as a transformation matrix, but you will have to do some conversion to turn that into an origin and normal. The following function shows how to do the conversion and get the intersection with a plane object.

# In stateutils
def cplaneIntersection(scene_viewer, ray_origin, ray_dir):
    # Find the intersection between the pointing ray and the construction
    # plane. Returns a hou.Vector3 representing the point in world space,
    # or raises an exception if the ray does not hit the plane

    # Grab a reference to the construction plane
    cplane = scene_viewer.constructionPlane()
    # Get the construction plane's transform matrix
    xform = cplane.transform()  # type: hou.Matrix4

    # The "rest" position for the construction plane has origin=0, 0, 0
    # and normal=0, 0, 1. We can multiply the current transform matrix by
    # those vectors to get the current origin and normal.
    cplane_origin = hou.Vector3(0, 0, 0) * xform  # type: hou.Vector3
    cplane_normal = hou.Vector3(0, 0, 1) * xform.inverted().transposed()

    # Use convenience function in hmath to find intersection
    return hou.hmath.intersectPlane(
        cplane_origin, cplane_normal, ray_origin, ray_dir
    )

# Grab a reference to a scene viewer's construction plane.
# Note you could do scene_viewer.referencePlane() to get the
# reference plane instead.
cplane = scene_viewer.constructionPlane()
# Given a ray origin and direction, computer the intersection
# point with the construction plane
point = cplane_intersection(cplane, origin, direction)

Flexible intersection

For some tools, you might want to project onto geometry if it’s available, or onto the construction plane if the pointing ray doesn’t hit any geometry. For example:

from __future__ import print_function

from stateutils import sopGeometryIntersection


class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer
        self._geometry = None

    def onEnter(self, kwargs):
        node = kwargs["node"]
        inputs = node.inputs()
        if inputs and inputs[0]:
            self._geometry = inputs[0].geometry()

    def onMouseEvent(self, kwargs):
        node = kwargs["node"]
        ui_event = kwargs["ui_event"]
        device = ui_event.device()
        origin, direction = ui_event.ray()

        intersected = -1
        inputs = node.inputs()
        if inputs and inputs[0]:
            # Only try intersecting geometry if the node has input
            intersected, position = sopGeometryIntersection(
                self._geometry, origin, direction
            )
        if intersected < 0:
            # Either there was no incoming geometry or the ray missed, so
            # try intersecting the construction plane
            position = cplaneIntersection(self.scene_viewer, origin, direction)

        print("position=", position)

Snapping from intersected geometry

hou.ViewerEvent.raySnapping is great for snapping a ray position to Houdini construction or reference planes. But if you need to snap a ray position to geometry components then you need viewerstate.utils.GeometryIntersector.

viewerstate.utils.GeometryIntersector is a utility class which provides the same services as sopGeometryIntersection() for intersecting geometries. But beyond that, it provides a set of useful functionalities for snapping geometry components from a geometry intersection point. Snapping works in conjunction with the settings of the Snap Options dialog available in the toolbar on the left of the Houdini viewer pane. If the Snap Options settings are not enabled, GeometryIntersector simply perforns a regular geometry intersection.

These GeometryIntersector members are used for storing the snapping results:

Member

Type

Content

snap_mode

hou.snappingMode

Snapping operation mode.

snap_gravity

int

The snap gravity value as set for the snapping mode. Used as a weighted value for fine tuning the snap operation.

snap_priority

hou.snappingPriority

Indicates the priority value used for snapping.

snapped_position

hou.Vector3

Closest snapped position to the intersected point.

snapped_edge

hou.Edge

Snapped geometry edge closest to the intersected point.

snapped_point_num

int

The snapped primitive point index.

test_dist

double

Minimal distance value for snapping components. A snapping operation succeeds if the distance between the component to snap and the intersected point is within test_dist.

tolerance

double

Tolerance value used for intersecting the geometry (see hou.Geometry.intersect()).

GeometryIntersector performs snapping based on these 2 Houdini snapping mode: point and multi.

  • In point mode, snapped_position is set with the primitive point closest to the intersected point. snap_priority is set to hou.snappingPriority.GeoPoint to indicate which type of geometry component has snapped.

  • In multi mode, different snap operations are evaluated based on the snapping priorities set in the Multi tab of the Snap Options window. The order of execution of these operations are determined by the snapping priority settings:

    GeoPoint

    This will get the geometry point closest to the intersected point, same as the point mode.

    GeoEdge

    This operation sets snap_edge with the closest edge from the intersected point. snapped_position is set with the projection of the intersected point onto the edge. snap_priority is set hou.snappingPriority.GeoEdge.

    Midpoint

    snapped_position is set with the midpoint of the closest edge to the intersected point. snap_priority is set to hou.snappingPriority.Midpoint.

Here’s a simple GeometryIntersector usage:

from __future__ import print_function

import viewerstate.utils as su

class MyState(object):
    def __init__(self, state_name, scene_viewer):
        self.state_name = state_name
        self.scene_viewer = scene_viewer
        self.geometry = None

    def onEnter(self, kwargs):
        node = kwargs["node"]
        inputs = node.inputs()
        if inputs and inputs[0]:
            self.geometry = inputs[0].geometry()

    def onMouseEvent(self, kwargs):
        ui_event = kwargs["ui_event"]       
        (mouse_point, mouse_dir) = ui_event.ray()

        gi = su.GeometryIntersector(self.geometry, scene_viewer=self.scene_viewer)
        gi.intersect(mouse_point, mouse_dir)

        # log trace in viewer state console
        self.trace( gi )

Load the $HH/viewer_states/examples/snap_demo.hip to see GeometryIntersector in action.

Compensating for parent transforms

If you want to display Drawable guide geometry based on the ray (for example, display a "cursor" guide in the scene showing the intersection position), you should transform local coordinates into world space.

See compensating guide transforms for more information.

Interrupt and Resume events

Method name

Called by Houdini

Notes

onInterrupt

state is interrupted

This method is called when:

  • The window loses focus.

  • The pointer leaves the viewer (including moving over a menu).

  • The user pushes a "volatile" tool (for example, holding down S to enter the volatile selection tool).

onResume

interruption ends

This method is called when:

  • The pointer re-enters the viewer.

  • The user pops back to this state from a "volatile" tool (for example, releasing S after using the volatile selection tool).

  • If you're remembering and comparing the state of mouse buttons in the onMouseEvent() handler to tell if the mouse button is being held down, you should also implement onInterrupt() and make it act like the user releasing the mouse button.

    Note that, for the left mouse button, you don’t need to track its state manually, you can use the UIEvent reason.

  • If you want to show something when the mouse pointer is in the viewer (for example a guide geometry preview under the mouse pointer), and hide it when the user is doing something else, you should hide in onInterrupt() but show in onMouseEvent() instead of onResume(). The state is active and the mouse pointer is in the viewer, by definition, when onMouseEvent() is called, and using onMouseEvent() instead of onResume() allows you to update what you're showing based on the mouse position if needed.

Python viewer states

Python Scripting

Getting started

Next steps

Python viewer states

You can write viewer states in Python that let you customize user interaction in the viewport for your node.

Guru level

Reference

  • hou

    Module containing all the sub-modules, classes, and functions to access Houdini.

  • Alembic extension functions

    Utility functions for extracting information from Alembic files.