Python usage#

These examples progress from one query to a complete interaction. The Overview defines the API contracts and return codes.

Prerequisites#

These examples use Kit’s Script Editor, not system Python. All examples use the shared framework below. Enable omni.kit.raycast.query and use an RTX viewport with an open stage.

Copy this framework into the Script Editor. At the placeholder, paste the example you want to try and its stated run command. The framework itself doesn’t submit queries or change your scene.

import asyncio
import math

import omni.kit.app
import omni.kit.raycast.query as rq
import omni.usd
from pxr import Gf, UsdGeom


def current_stage():
    stage = omni.usd.get_context().get_stage()
    if stage is None:
        raise RuntimeError("Open a stage before running a raycast demo")
    return stage


async def _wait(future, stage, frames):
    try:
        for _ in range(frames):
            if omni.usd.get_context().get_stage() != stage:
                raise RuntimeError("Stage changed while waiting for the raycast")
            if future.done():
                return future.result()
            await omni.kit.app.get_app().next_update_async()
        raise TimeoutError(
            "No raycast result; ensure RTX and an updating viewport are active")
    finally:
        if not future.done():
            future.cancel()  # Ignore delivery, not cancel GPU work.


# Paste example code here, followed by its run command.

The wait helper yields Kit updates, rejects stage changes, and bounds the wait. For asynchronous examples, task.cancel() stops waiting and ignores late callbacks; it does not cancel GPU work. All excerpts come from the tested example/query_workflow.py packaged with the extension.

Submit a single raycast#

Paste this block at the placeholder and run the combined code. It submits a ray from (100, 0, 0) toward negative X in world-space stage coordinates and prints either the hit path, position, and ray parameter; or miss. Adjust the position or direction to aim at various geometry in your scene.

async def single_query(ray=None, frames=120):
    """One callback-based query, awaited without blocking Kit's update loop."""
    stage = current_stage()
    ray = ray if ray is not None else rq.Ray((100, 0, 0), (-1, 0, 0))
    future = asyncio.get_running_loop().create_future()

    def on_hit(completed_ray, result):
        if not future.done() and omni.usd.get_context().get_stage() == stage:
            future.set_result((completed_ray, result))

    rq.acquire_raycast_query_interface().submit_raycast_query(ray, on_hit)
    completed_ray, result = await _wait(future, stage, frames)
    if result.valid:
        print(result.get_target_usd_path(), tuple(result.hit_position), result.hit_t)
    else:
        print("miss")
    return completed_ray, result


if __name__ == "__main__":
    # Change the world-space origin and direction to aim at your geometry.
    task = asyncio.ensure_future(single_query(rq.Ray((100, 0, 0), (-1, 0, 0))))

Convert physical distances and stage units#

Check result.valid before reading hit data. We don’t recommend dropping to defaults on misses, but rather treating them as misses.

Result positions use world-space stage coordinates. hit_t, min_t, and max_t multiply the direction; meaning that they are “distances” only for normalized directions. If the direction is a vector with magnitude > 1, then *_t fields will essentially be distance / mag(direction). See ray parameters and result interpretation.

USD’s metersPerUnit states how many meters a stage unit represents. For example, at 0.01, ten units represent ten centimeters.

def meters_per_stage_unit(stage):
    value = float(UsdGeom.GetStageMetersPerUnit(stage))
    if not math.isfinite(value) or value <= 0:
        raise ValueError("metersPerUnit must be finite and positive")
    return value


def meters_to_stage_units(stage, meters):
    return float(meters) / meters_per_stage_unit(stage)


def stage_units_to_meters(stage, distance):
    return float(distance) * meters_per_stage_unit(stage)


def units_demo():
    stage = current_stage()
    distance = meters_to_stage_units(stage, 0.1)
    print(f"0.1 meters = {distance:g} stage units")
    print(f"Converted back: {stage_units_to_meters(stage, distance):g} meters")

Run units_demo() at the end of the script to execute this example. The example conversion rejects nonpositive/nonfinite metadata.

Poll a sequence containing one ray#

Sequences are re-queried every update until removed, even if you don’t change the ray information. This demonstration reuses one sequence: first the ray travels along negative X from (100, 0, 0), then from (100, 100, 0).

Hits and misses depend on your scene. A size-20 cube at the origin would yield a hit followed by a miss if no other geometry intersects the rays.

Both results are returned, and then the sequence gets removed; continuous tools would retain polling for their lifetime. You should compare returned rays with the intended request: SUCCESS alone may describe an older batch. See sequence contracts.

async def single_sequence(frames=120):
    stage = current_stage()
    interface = rq.acquire_raycast_query_interface()
    sequence = interface.add_raycast_sequence()
    # Reuse one sequence: first hit the cube, then move the ray to miss it.
    rays = [rq.Ray((100, 0, 0), (-1, 0, 0)),
            rq.Ray((100, 100, 0), (-1, 0, 0))]
    results = []
    try:
        for ray in rays:
            status = interface.submit_ray_to_raycast_sequence(sequence, ray)
            if status != rq.Result.SUCCESS:
                raise RuntimeError(f"Ray submission failed: {status}")
            for _ in range(frames):
                if omni.usd.get_context().get_stage() != stage:
                    raise RuntimeError("Stage changed; the sequence is no longer valid")
                status, completed, result = (
                    interface.get_latest_result_from_raycast_sequence(sequence)
                )
                # SUCCESS can still describe the previous ray while this one runs.
                if status == rq.Result.SUCCESS and completed == ray:
                    results.append(result)
                    print(result.get_target_usd_path() if result.valid else "miss")
                    break
                if status not in (rq.Result.SUCCESS, rq.Result.RAYCAST_RESULT_NOT_READY):
                    raise RuntimeError(f"Raycast failed: {status}")
                await omni.kit.app.get_app().next_update_async()
            else:
                raise TimeoutError("Sequence did not produce a matching result")
        return results
    finally:
        # Closing the stage already removes its sequences.
        if omni.usd.get_context().get_stage() == stage:
            interface.remove_raycast_sequence(sequence)

Run task = asyncio.ensure_future(single_sequence()) at the end of the script to execute this example.

Poll a sequence containing an array of rays#

The first batch submits both rays from the preceding example; the replacement batch reverses their order.

Results from the earlier batch are ignored until the returned rays match the replacement.

Python sets the array size automatically. Note that C++ requires explicit sizing.

async def array_sequence(frames=120):
    stage = current_stage()
    interface = rq.acquire_raycast_query_interface()
    sequence = interface.add_raycast_sequence()
    hit = rq.Ray((100, 0, 0), (-1, 0, 0))
    miss = rq.Ray((100, 100, 0), (-1, 0, 0))
    batches = []
    try:
        for rays in ([hit, miss], [miss, hit]):
            status = interface.submit_ray_to_raycast_sequence_array(sequence, rays)
            if status != rq.Result.SUCCESS:
                raise RuntimeError(f"Array submission failed: {status}")
            for _ in range(frames):
                if omni.usd.get_context().get_stage() != stage:
                    raise RuntimeError("Stage changed; the sequence is no longer valid")
                status, completed, results = (
                    interface.get_latest_result_from_raycast_sequence_array(sequence)
                )
                if status == rq.Result.SUCCESS and list(completed) == rays:
                    batches.append(list(results))
                    print([result.valid for result in results])
                    break
                if status not in (rq.Result.SUCCESS, rq.Result.RAYCAST_RESULT_NOT_READY):
                    raise RuntimeError(f"Raycast failed: {status}")
                await omni.kit.app.get_app().next_update_async()
            else:
                raise TimeoutError("Sequence did not produce a matching batch")
        return batches
    finally:
        if omni.usd.get_context().get_stage() == stage:
            interface.remove_raycast_sequence(sequence)

Run task = asyncio.ensure_future(array_sequence()) at the end of the script to execute this example.

Keep controller callbacks independent#

This pattern applies to any independent sources, not just controllers. You should capture stable source identities, not infer identity from completion order.

The example allows one outstanding query per source and skips pose updates while pending. We may submit the current pose again after completion. (This is just an example policy, not any kind of API requirement.)

class RequestDispatcher:
    """Example policy: at most one outstanding request per source.

    start() enables stage-lifetime observation; close() removes it and drops
    pending callbacks. The optional submit function permits controlled tests.
    """
    def __init__(self, on_result, submit=None):
        self.on_result = on_result
        self._submit = submit
        self._pending = {}
        self._subscription = None

    def start(self):
        self.close()
        events = omni.usd.get_context().get_stage_event_stream()
        self._subscription = events.create_subscription_to_pop(
            self._on_stage, name="raycast.example.requests")
        return self

    def submit(self, source, origin, direction):
        if source in self._pending:
            return False
        forward = Gf.Vec3d(*direction)
        if forward.GetLength() == 0:
            raise ValueError("direction must not be zero")
        ray = rq.Ray(origin, forward.GetNormalized())
        stage = current_stage()
        token = object()
        self._pending[source] = token

        def completed(completed_ray, result):
            if self._pending.get(source) is not token:
                return
            del self._pending[source]
            if omni.usd.get_context().get_stage() == stage:
                self.on_result(source, completed_ray, result)

        submit = self._submit or rq.acquire_raycast_query_interface().submit_raycast_query
        try:
            submit(ray, completed)
        except Exception:
            self._pending.pop(source, None)
            raise
        return True

    def cancel(self, source):
        self._pending.pop(source, None)  # Does not cancel GPU work.

    def _on_stage(self, event):
        closing, opened = omni.usd.StageEventType.CLOSING, omni.usd.StageEventType.OPENED
        if event.type in (int(closing), int(opened)):
            self._pending.clear()

    def close(self):
        self._subscription = None
        self._pending.clear()

Construct requests = RequestDispatcher(print).start(), then call requests.submit("left", (100, 0, 0), (-1, 0, 0)) and independently submit for "right".

Cancel with requests.cancel("left") and finish with requests.close(). Stage changes invalidate pending deliveries; the host must also clear any cached previews on disable or stage change.

Filtering raycasts with pickability#

Pickability is shared renderer state, not a per-query filter or physics collision control. Read the pickability contract and limitations before using this technique, especially subtree lifetime, coplanar surfaces, and the finite traversal budget.

Include the single_query function from the first example beneath the framework as well; omit that example’s launch command.

async def filtered_query(path, ray=None):
    """Temporarily exclude a prim that the caller knows is currently pickable."""
    context = omni.usd.get_context()
    stage = current_stage()
    if not stage.GetPrimAtPath(path):
        raise ValueError(f"No prim at {path}")
    try:
        context.set_pickable(path, False)
        await omni.kit.app.get_app().next_update_async()
        if context.get_stage() != stage:
            raise RuntimeError("Stage changed while preparing the filtered raycast")
        return await single_query(ray)
    finally:
        if context.get_stage() == stage:
            context.set_pickable(path, True)

Run task = asyncio.ensure_future(filtered_query("/World/Target")) at the end of the script to execute the example, replacing the path with an existing prim that you know is pickable. Optionally supply a ray aimed at that prim. The query ignores that prim but may hit geometry behind it. This recipe restores a known pickable state, not arbitrary previous user state; don’t use it on a prim that another tool has made non-pickable.

Use this recipe only while you own that subtree’s pickability: overlapping temporary filters or another tool changing the same state can undo each other’s filter. Keep the subtree intact until the query finishes so cleanup restores the same renderable paths.

Note that a Kit update isn’t reliable as a public renderer completion fence.

Use a cone-based snap tolerance#

When using 3D inputs (e.g., XR controllers), it can often be useful to add a cone range when you would normally apply a pixel-threshold on a desktop raycast.

Angular snapping here is actually an application-level post-processing of a valid single hit, not a cone cast.

At candidate depth d, the acceptance radius becomes d * tan(half_angle). This scales with distance independently of stage units.

Choose the accepted candidate nearest the surface hit, or retain the hit if none is accepted.

The caller supplies world-space vertices in the same coordinates as the ray.

General renderer-ID-to-USD-topology mapping, animation, and instancing are outside this example.

def angular_snap(origin, direction, surface_point, candidates, half_angle_degrees=1.0):
    """Select a world-space vertex inside the cone, or retain the surface hit.

    The caller supplies candidates in the same stage/world coordinate space.
    This function does not interpret renderer primitive IDs or USD topology.
    """
    if not math.isfinite(half_angle_degrees) or not 0 < half_angle_degrees < 90:
        raise ValueError("half_angle_degrees must be finite and between 0 and 90")
    origin, direction = Gf.Vec3d(*origin), Gf.Vec3d(*direction)
    if direction.GetLength() == 0:
        raise ValueError("direction must not be zero")
    direction = direction.GetNormalized()
    surface = Gf.Vec3d(*surface_point)
    slope = math.tan(math.radians(half_angle_degrees))
    accepted = []
    for candidate in candidates:
        point = Gf.Vec3d(*candidate)
        delta = point - origin
        depth = Gf.Dot(delta, direction)
        radius = (delta - direction * depth).GetLength()
        if depth > 0 and radius <= depth * slope:
            accepted.append(point)
    return min(accepted, key=lambda point: (point - surface).GetLength(), default=surface)


def angular_demo():
    # Deliberately explicit candidates: no assumption about renderer face IDs.
    point = angular_snap((100, 0, 0), (-1, 0, 0), (10, 0, 0), [(10, 1, 0), (10, 5, 0)])
    print(tuple(point))  # (10, 1, 0)
    return point

Run angular_demo() at the end of the script to execute this example.

Raycast painting example#

The packaged example/raycast_painting.py script demonstrates this technique in an interactive placement workflow. It expects:

  • An open stage containing /World/Brush whose root is both UsdGeom.Xformable and UsdGeom.Imageable (for example, an Xform, Cube, or Mesh), with a usable render bound. The brush itself may be invisible. A Scope or untyped container root is rejected even if its children have geometry.

  • omni.kit.raycast.query and omni.kit.viewport.utility to be enabled.

With those prerequisites in place, run this in the Script Editor:

import runpy
from pathlib import Path
import omni.kit.app

manager = omni.kit.app.get_app().get_extension_manager()
folder = Path(manager.get_extension_path_by_module("omni.kit.raycast.query")) / "example"
painting = runpy.run_path(str(folder / "raycast_painting.py"))
painting["start"]()

Call painting["stop"]() to close the window and release input and preview resources. Importing does not start the demo. Stop the previous demo before loading the file again with runpy.run_path(). Enable Painting in the example window. While painting is enabled, left-button input in the active viewport is consumed by the example. Pressing or dragging submits raycasts and positions a visible preview. Releasing over a valid hit creates an instanceable internal reference below /World/Placed so later placements can be stacked on earlier ones.

Canceling a drag hides its preview. Closing or replacing the stage disables painting and releases its input overlay; enable it again for the new stage. When using RaycastBrushTechnique in your own tool, its host must call destroy() when the stage closes.

The preview is created below /World/temp/preview. The example waits for its geometry to be realized before making the existing preview descendants non-pickable. It then waits through another Kit update before resuming raycasts. Preview collision is disabled separately. A small display-only normal offset prevents the preview from being coplanar with the intended hit surface; final placement uses the unmodified hit position.

The focused interaction class is included below. Private helpers in the packaged script handle brush bounds, preview preparation, reference authoring, and callback bookkeeping; the live demo harness only creates the checkbox and consumes viewport input.



class RaycastBrushTechnique:
    """Raycast, preview, and place references to /World/Brush.

    A host calls begin(), drag(), end(), and cancel(), and destroys the technique
    when its stage closes. This class deliberately knows nothing about windows,
    checkboxes, or Kit's viewport gesture system.
    """

    def __init__(self, stage, viewport_api, status_fn=None):
        self._viewport_api = viewport_api
        self._status_fn = status_fn or (lambda _text: None)
        self._active = True
        self._stroke = _BrushStrokeState()
        self._brush_scene = _BrushScene(stage, self._on_preview_ready)

    def begin(self, mouse_ndc):
        self._stroke.begin(mouse_ndc)
        self._flush_pending_raycast()

    def drag(self, mouse_ndc):
        self._stroke.drag(mouse_ndc)
        self._flush_pending_raycast()

    def end(self):
        self._stroke.end()
        self._finish_release_if_ready()

    def cancel(self):
        self._stroke.cancel()
        self._brush_scene.hide_preview()

    def _flush_pending_raycast(self):
        if not self._active or self._brush_scene.preview_preparing:
            return

        pending = self._stroke.take_pending(self._brush_scene.preview_absent)
        if pending is None:
            return
        mouse_ndc, token = pending

        def on_complete(_ray, result):
            if not self._active or not self._stroke.accept(token):
                return

            # Multiple queries may be in flight. Accept a result unless a newer
            # one has already arrived, so late callbacks cannot move backward.
            if not result.valid:
                self._stroke.latest_hit = None
                self._brush_scene.hide_preview()
                self._status_fn("Painting: ray missed")
            else:
                position = Gf.Vec3d(*result.hit_position)
                normal = Gf.Vec3d(*result.normal).GetNormalized()
                placement = self._brush_scene.make_placement(position, normal)
                hit_path = result.get_target_usd_path()
                self._stroke.latest_hit = (hit_path, position, normal, placement)
                self._brush_scene.show_preview(placement, normal)
                self._status_fn(f"Hit {hit_path}")

            self._flush_pending_raycast()
            self._finish_release_if_ready()

        raycast_utils.raycast_from_mouse_ndc(
            mouse_ndc, self._viewport_api, on_complete
        )

    def _finish_release_if_ready(self):
        ready, hit = self._stroke.take_released_hit()
        if not ready or hit is None:
            return

        hit_path, position, normal, placement = hit
        carb.log_info(
            "[raycast-painting-example] Release commits hit "
            f"{hit_path} at {tuple(position)} with normal {tuple(normal)}"
        )
        placed_path = self._brush_scene.place(placement)
        self._brush_scene.hide_preview()
        self._status_fn(f"Placed {placed_path}")

    def _on_preview_ready(self):
        self._flush_pending_raycast()
        self._finish_release_if_ready()

    def destroy(self):
        if not self._active:
            return
        self._active = False
        self._stroke.cancel()
        self._brush_scene.destroy()