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/Brushwhose root is bothUsdGeom.XformableandUsdGeom.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.queryandomni.kit.viewport.utilityto 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()