Houdini 20.5 Python scripting

Python state user interface events

How to listen for and respond to direct UI input.

On this page

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 to handle low-level events.

  • 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.

onKeyTransitEvent

keyboard keys transition events

See reading the keyboard device below.

onMouseEvent

input device moves/clicks

See mouse handling below.

onMouseDoubleClickEvent

mouse double clicks

See mouse double click handling 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.

Consuming input events

Python states have priority for processing mouse and keyboard events. All input event callbacks can optionally consume the received event by returning True, this tells Houdini to break the chain of event consumers and to stop the event processing. Returning False marks the event as not consumed which is the default behavior.

Warning

Consuming an event may cause some troubles. For instance, if onMouseEvent returns True to indicate the event has been consumed, the active selector will not receive the mouse event and will break. Events consuming must be done with care.

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 onMouseEvent(self, kwargs):
        # Get the UI event
        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("LMB released=", device.isLeftButtonReleased())
        print("MMB released=", device.isMiddleButtonReleased())
        print("RMB released=", device.isRightButtonReleased())
        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 hou.UIEventDevice.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.

Tip

An alternative to hou.uiEventReason.Changed to see if the left mouse button was released is to call hou.UIEventDevice.isLeftButtonReleased(). To see if the middle button was released call hou.UIEventDevice.isMiddleButtonReleased(). For the right button call hou.UIEventDevice.isRightButtonReleased()

Left mouse button double click

You can trap the double click event with the onMouseDoubleClickEvent handler. This event is triggered when the left mouse button is depressed 2 times in a quick sequence. The handler is always called after onMouseEvent has been processed.

Warning

Like onMouseEvent, onMouseDoubleClickEvent is always processed before the active selector (if any). If onMouseDoubleClickEvent doesn’t consume the event (returns False), the event is passed to the active selector. However, when onMouseDoubleClickEvent consumes the event (returns True), Houdini will not pass the event to the active selector and will likely break.

Right mouse button

The right mouse button (RMB) event will not be processed by onMouseEvent if the python state has a bound custom menu.

In the case where the python state has no bound custom menu, the RMB event is sent to onMouseEvent if all these conditions are met:

  • No handle is active in the viewport or, if there is one, the RMB event did not trigger the active handle menu.

  • No selector is active.

  • No geometry or object exists in the viewport or, if there is one, the RMB event did not trigger the geometry or object menu.

def onMouseEvent(self, kwargs):
    ui_event = kwargs["ui_event"]

    if ui_event.device().isLeftButton():
        self.log("LMB click")
    elif ui_event.device().isMiddleButton():
        self.log("MMB click")
    elif ui_event.device().isRightButton():
        self.log("RMB click")
        return True

    return False

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 scroll 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 from single keys to key combinations with 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.

Transitory key events, like up and down key transitions, are monitored with the onKeyTransitEvent handler. Use this method if you need to know when a key was released or pressed down. See hou.UIEventDevice.isKeyUp and hou.UIEventDevice.isKeyDown for more.

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

    # Log some key event info in the Viewer State Browser console
    self.log( 'key string', ui_event.device().keyString() )
    self.log( 'key value', ui_event.device().keyValue() )
    self.log( 'isAutoRepeat', ui_event.device().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
def onKeyTransitEvent(self, kwargs):
    ui_event = kwargs['ui_event']

    # Log the key state
    self.log( 'key', ui_event.device().keyString() )
    self.log( 'key up', ui_event.device().isKeyUp() )
    self.log( 'key down', ui_event.device().isKeyDown() )

    # return True to consume the key transition
        if ui_event.device().isKeyDown():
                return True
    return False

Consumer chain

The keyboard event consumer chain for python states is as follows

  • onKeyEvent

  • onMenuAction

  • Active state selector

The python state has a first crack at the event with onKeyEvent, then with onMenuAction via a symbolic hotkey (if any). Lastly, the active selector has a turn at consuming the event. onKeyEvent should return True if the event is consumed, otherwise the key could be consumed a second time by onMenuAction.

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 onMouseEvent(self, kwargs):
        # Get the UI event
        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())

        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 dictionary containing origin_point, direction, and snapped, where the third item is a boolean indicating whether the ray was snapped. If snapped is True, then other values will be included in the dictionary which indicate what was snapped to. See Snapping to Geometry for more information.

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"]
        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"]
        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.

Snapping to Geometry

hou.ViewerEvent.ray is great for finding intersections with geometry or the Houdini construction or reference planes. But if you need to snap a ray position to geometry components then you need hou.ViewerEvent.snappingRay.

hou.ViewerEvent.snappingRay returns a dictionary containing the “pointing ray” as well as information about the snap, if one occurred. It uses the snapping options specified in the Snap Options dialog available in the toolbar on the left of the Houdini viewer pane. If the Snap Options are not enabled, hou.ViewerEvent.snappingRay will not snap to anything.

For example, in an “inspector” state, you might want to check which point the user is hovering near:

def onMouseEvent(self, kwargs):
    ui_event = kwargs["ui_event"]
    snap_dict = ui_event.snappingRay()

    if snap_dict["snapped"] and snap_dict["geo_type"] == hou.snappingPriority.GeoPoint:
        self.log("You snapped to a point:")
        self.log(snap_dict["point_index"])

If a state is only interested in a particular type of snapping, it can set the snapping mode of the viewport with hou.SceneViewer.snappingMode. Make sure that the state reverts to the previous snapping mode when it is done:

def onEnter(self, kwargs):
    self._snap_mode = self.scene_viewer.snappingMode()
    self.scene_viewer.setSnappingMode(hou.snappingMode.Point)

def onInterrupt(self, kwargs):
    self.scene_viewer.setSnappingMode(self._snap_mode)

def onResume(self, kwargs):
    self._snap_mode = self.scene_viewer.snappingMode()
    self.scene_viewer.setSnappingMode(hou.snappingMode.Point)

def onExit(self, kwargs):
    self.scene_viewer.setSnappingMode(self._snap_mode)

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)

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 scripting

Getting started

Next steps

Reference

  • hou

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

Guru level

Python viewer states

Python viewer handles

Plugin types