Skip to content

Grabber

Breaking change: grab is ray-based

grab no longer takes a world-coordinate point. It always grabs a grabbable within radius metres of the desktop cursor ray's hit point — aim first with CursorClient.set_position. A ray miss is reported as GrabResult.grabbed == False (not an error); VR mode fails with FAILED_PRECONDITION.

Runnable example

python/examples/grabber_grab.py — a full positive pick-up: spawn a Mirror from the inventory, hold the cursor on it, grab at the ray hit point, then release. The grabbed object stays at the cursor position where it was grabbed and follows the hand from there.

Operating the held / equipped item

After grabbing, drive the interactions an avatar can perform on the held object:

  • use presses a button (primary = left-click, secondary = right-click) and holds it down until unuse. While grabbing, a primary press aligns the object; while a tool is equipped it activates the tool. The hold persists across RPCs, so a Pen can be pressed, dragged via CursorClient.set_position, then released to draw a stroke. The optional strength (0..1, default 1.0) is the analog pressure of the primary press — a BrushTool/Pen reads it as pen pressure — and is ignored for the secondary button.
  • click is a convenience for a single press+release (e.g. one-shot align).
  • equip / dequip equip a grabbed tool into the hand / remove it. equip is a no-op when no ITool is grabbed.

GrabState.held_buttons, is_tool_equipped, and equipped_tool_name report the resulting state.

resoio.grabber.GrabberClient

GrabberClient(socket_path: str | None = None)

Bases: _BaseClient[GrabberStub]

Async client for the Resonite IO Grabber service over a UDS.

Use as an async context manager so the gRPC channel is closed deterministically. Socket resolution mirrors :class:resoio.ConnectionClient.

Source code in src/resoio/_client.py
def __init__(self, socket_path: str | None = None) -> None:
    # Defer resolution to __aenter__ so env vars patched between
    # construction and connection are honoured, and so resolution
    # errors surface at the connect site.
    self._explicit_path: str | None = socket_path
    self._channel: Channel | None = None
    self._stub: TStub | None = None
    self._resolved_path: str | None = None

grab async

grab(*, hand: GrabberHandArg = 'primary', radius: float = 0.0) -> GrabResult

Grab a grabbable near the cursor ray hit point.

Grabs a grabbable within radius metres (<= 0 lets the server apply its default, 0.1m) of the current desktop cursor ray's hit point. Aim beforehand with :meth:resoio.CursorClient.set_position. A ray miss or nothing grabbable in range is reported as GrabResult.grabbed == False, not an error. In VR mode the call fails with :class:grpclib.exceptions.GRPCError (FAILED_PRECONDITION).

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def grab(
    self,
    *,
    hand: GrabberHandArg = "primary",
    radius: float = 0.0,
) -> GrabResult:
    """Grab a grabbable near the cursor ray hit point.

    Grabs a grabbable within ``radius`` metres (``<= 0`` lets the
    server apply its default, 0.1m) of the current desktop cursor
    ray's hit point. Aim beforehand with
    :meth:`resoio.CursorClient.set_position`. A ray miss or nothing
    grabbable in range is reported as
    ``GrabResult.grabbed == False``, not an error. In VR mode the
    call fails with :class:`grpclib.exceptions.GRPCError`
    (``FAILED_PRECONDITION``).

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberGrabRequest(hand=_hand_to_proto(hand), radius=radius)
    return await self._dispatch(lambda stub: stub.grab(request), _result_from_proto)

release async

release(*, hand: GrabberHandArg = 'primary') -> GrabState

Release everything the hand is holding and return the new state.

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def release(self, *, hand: GrabberHandArg = "primary") -> GrabState:
    """Release everything the hand is holding and return the new state.

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberReleaseRequest(hand=_hand_to_proto(hand))
    return await self._dispatch(
        lambda stub: stub.release(request), _state_from_proto
    )

get_state async

get_state(*, hand: GrabberHandArg = 'primary') -> GrabState

Return the hand's current hold state without modifying it.

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def get_state(self, *, hand: GrabberHandArg = "primary") -> GrabState:
    """Return the hand's current hold state without modifying it.

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberGetStateRequest(hand=_hand_to_proto(hand))
    return await self._dispatch(
        lambda stub: stub.get_state(request), _state_from_proto
    )

use async

use(
    *,
    hand: GrabberHandArg = "primary",
    button: GrabberButtonArg = "primary",
    strength: float = 1.0,
) -> GrabState

Press button and hold it down until :meth:unuse.

button="primary" is a left-click, "secondary" a right-click. While grabbing, a primary press aligns the held object; while a tool is equipped it activates the tool. The button stays held (re-injected every engine frame), so a Pen can be pressed, dragged via :meth:resoio.CursorClient.set_position, then released with :meth:unuse to draw a stroke.

strength is the analog press strength (0..1) of the primary button, e.g. the pen pressure a BrushTool reads (default 1.0). It is held at the same value for the duration of the hold and is ignored for button="secondary" (which is digital only). Out-of-range values are clamped by the server.

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def use(
    self,
    *,
    hand: GrabberHandArg = "primary",
    button: GrabberButtonArg = "primary",
    strength: float = 1.0,
) -> GrabState:
    """Press ``button`` and hold it down until :meth:`unuse`.

    ``button="primary"`` is a left-click, ``"secondary"`` a
    right-click. While grabbing, a primary press aligns the held
    object; while a tool is equipped it activates the tool. The
    button stays held (re-injected every engine frame), so a Pen can
    be pressed, dragged via
    :meth:`resoio.CursorClient.set_position`, then released with
    :meth:`unuse` to draw a stroke.

    ``strength`` is the analog press strength (0..1) of the primary
    button, e.g. the pen pressure a ``BrushTool`` reads (default
    ``1.0``). It is held at the same value for the duration of the
    hold and is ignored for ``button="secondary"`` (which is digital
    only). Out-of-range values are clamped by the server.

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberUseRequest(
        hand=_hand_to_proto(hand),
        button=_button_to_proto(button),
        strength=strength,
    )
    return await self._dispatch(lambda stub: stub.use(request), _state_from_proto)

unuse async

unuse(
    *, hand: GrabberHandArg = "primary", button: GrabberButtonArg = "primary"
) -> GrabState

Release button previously held via :meth:use (no-op if not held).

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def unuse(
    self,
    *,
    hand: GrabberHandArg = "primary",
    button: GrabberButtonArg = "primary",
) -> GrabState:
    """Release ``button`` previously held via :meth:`use` (no-op if not
    held).

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberUnuseRequest(
        hand=_hand_to_proto(hand), button=_button_to_proto(button)
    )
    return await self._dispatch(lambda stub: stub.unuse(request), _state_from_proto)

click async

click(
    *,
    hand: GrabberHandArg = "primary",
    button: GrabberButtonArg = "primary",
    strength: float = 1.0,
) -> GrabState

Press and release button once (a :meth:use then :meth:unuse).

A convenience for single-shot interactions such as aligning a grabbed object, where the hold of :meth:use is not needed. The two RPCs run on separate engine ticks so the press registers before the release. Returns the state after :meth:unuse.

strength is forwarded to the :meth:use press (analog primary press strength 0..1, default 1.0, ignored for secondary); :meth:unuse carries no strength.

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def click(
    self,
    *,
    hand: GrabberHandArg = "primary",
    button: GrabberButtonArg = "primary",
    strength: float = 1.0,
) -> GrabState:
    """Press and release ``button`` once (a :meth:`use` then
    :meth:`unuse`).

    A convenience for single-shot interactions such as aligning a
    grabbed object, where the hold of :meth:`use` is not needed. The
    two RPCs run on separate engine ticks so the press registers
    before the release. Returns the state after :meth:`unuse`.

    ``strength`` is forwarded to the :meth:`use` press (analog
    primary press strength 0..1, default ``1.0``, ignored for
    secondary); :meth:`unuse` carries no strength.

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    await self.use(hand=hand, button=button, strength=strength)
    return await self.unuse(hand=hand, button=button)

equip async

equip(*, hand: GrabberHandArg = 'primary') -> GrabState

Equip the grabbed tool into the hand (no-op if no tool is grabbed).

Searches the hand's grabbed objects for an ITool component and equips the first one found. After equipping, :meth:use activates the tool's function.

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def equip(self, *, hand: GrabberHandArg = "primary") -> GrabState:
    """Equip the grabbed tool into the hand (no-op if no tool is grabbed).

    Searches the hand's grabbed objects for an ``ITool`` component
    and equips the first one found. After equipping, :meth:`use`
    activates the tool's function.

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberEquipRequest(hand=_hand_to_proto(hand))
    return await self._dispatch(lambda stub: stub.equip(request), _state_from_proto)

dequip async

dequip(*, hand: GrabberHandArg = 'primary') -> GrabState

Remove the tool currently equipped on the hand (no-op if none).

gRPC failures surface as :class:grpclib.exceptions.GRPCError.

Source code in src/resoio/grabber.py
async def dequip(self, *, hand: GrabberHandArg = "primary") -> GrabState:
    """Remove the tool currently equipped on the hand (no-op if none).

    gRPC failures surface as :class:`grpclib.exceptions.GRPCError`.
    """
    request = GrabberDequipRequest(hand=_hand_to_proto(hand))
    return await self._dispatch(
        lambda stub: stub.dequip(request), _state_from_proto
    )

resoio.grabber.GrabResult dataclass

GrabResult(grabbed: bool, state: GrabState)

Result of a :meth:GrabberClient.grab call.

grabbed is True only when this call newly grabbed something; a ray miss or nothing grabbable in range is reported as grabbed=False rather than an error. state is the hold state after the call.

resoio.grabber.GrabState dataclass

GrabState(
    hand: GrabberHandArg,
    is_holding: bool,
    object_names: tuple[str, ...],
    unix_nanos: int,
    is_tool_equipped: bool,
    equipped_tool_name: str,
    held_buttons: tuple[GrabberButtonArg, ...],
)

Snapshot of what a hand is currently holding / operating.

hand echoes back which hand the server actually acted on, so a caller that passed "primary" learns whether it resolved to left or right (it is never "unspecified"UNSPECIFIED decodes as "primary"). object_names is a best-effort list of held grabbable slot names and may be empty even when is_holding is True. is_tool_equipped / equipped_tool_name describe the tool currently equipped on the hand (equipped_tool_name is empty when nothing is equipped). held_buttons lists the buttons currently held down via :meth:GrabberClient.use (cleared by :meth:GrabberClient.unuse).