Source code for vantagekit.kit.controls

"""ViewerControls: the rig, the input router and target selection wired with the default
actions. Apps add handlers for their own actions and their own binding layers.

    controls = ViewerControls("/World/ViewerCam", CallbackTargets(ids_fn, pose_fn))
    controls.on("pilot_toggle", my_toggle)
    ...
    # once per frame, from the app's update observer:
    frame = controls.update(dt)
    ...
    controls.dispose()
"""

from __future__ import annotations

import math
import time
from typing import Callable, Dict, Iterable, List, Sequence

import carb

from ..core.bindings import DEFAULT_BINDINGS, Binding, BindingState, Frame
from ..core.targets import TargetCycler, TargetProvider
from .input import InputRouter
from .native_nav import NativeNavigation
from .overlay import NAMES, ViewOverlay
from .rig import CameraRig, RigSettings

# Orbit input scales: a drag across the whole window turns half a circle; a wheel step
# changes the range by about 14 percent; full stick deflection turns 90 degrees a second;
# a full trigger doubles or halves the range in about half a second.
DRAG_RAD_PER_WINDOW = math.pi
WHEEL_LOG_STEP = 0.15
STICK_RAD_PER_S = math.radians(90.0)
TRIGGER_LOG_PER_S = 1.5


[docs] class ViewerControls: def __init__( self, camera_path: str, provider: TargetProvider, bindings: Iterable[Binding] | None = None, layers: Sequence[str] = ("camera", "orbit"), settings: RigSettings | None = None, free_gamepad: bool | None = None, order: int = 0, consume: bool = False, name: str = "vantagekit", overlay: bool = True, overlay_extra: Callable[[], str] | None = None, ): """overlay: draw the view badge over the viewport (an app with its own HUD can turn it off and draw view_badge() itself). overlay_extra: returns text the app appends to the badge title (for example "PILOT" while piloting).""" self.name = name self._overlay_on = overlay self._overlay = None self._overlay_retry_t = 0.0 self._overlay_extra = overlay_extra self.targets = TargetCycler(provider) self.native = NativeNavigation(free_gamepad=free_gamepad) self.rig = CameraRig(camera_path, self.targets, settings, self.native, name=name) self.state = BindingState(list(bindings) if bindings is not None else DEFAULT_BINDINGS, layers) self.router = InputRouter(self.state, order=order, consume=consume, name=name) self._handlers: Dict[str, Callable[[], None]] = { "attach": self.toggle_attach, "view_next": self.next_view, "target_prev": lambda: self.step_target(-1), "target_next": lambda: self.step_target(1), "home": self.rig.home, } self._mode_listeners: List[Callable[[str], None]] = [] self._target_listeners: List[Callable[[object], None]] = [] # wiring ---------------------------------------------------------------------
[docs] def on(self, action: str, fn: Callable[[], None]) -> None: """Handle a press action (replaces a default handler of the same name).""" self._handlers[action] = fn
[docs] def on_mode_change(self, fn: Callable[[str], None]) -> None: self._mode_listeners.append(fn)
[docs] def on_target_change(self, fn: Callable[[object], None]) -> None: self._target_listeners.append(fn)
[docs] def set_layer(self, layer: str, on: bool) -> None: self.state.set_layer(layer, on)
# actions ------------------------------------------------------------------
[docs] def set_mode(self, mode: str) -> str: before = self.rig.mode after = self.rig.set_mode(mode) self.state.set_layer("orbit", after == "orbit") if after != before: for fn in self._mode_listeners: fn(after) return after
[docs] def toggle_attach(self) -> str: return self.set_mode("free" if self.rig.attached else self.rig.view)
[docs] def next_view(self) -> str: if not self.rig.attached: return self.set_mode(self.rig.view) views = list(self.rig.settings.views) i = views.index(self.rig.mode) if self.rig.mode in views else -1 return self.set_mode(views[(i + 1) % len(views)])
[docs] def step_target(self, direction: int): tid = self.rig.step_target(direction) for fn in self._target_listeners: fn(tid) return tid
[docs] def select(self, tid) -> None: self.targets.selected = tid for fn in self._target_listeners: fn(tid)
# per frame ------------------------------------------------------------------
[docs] def update(self, dt: float) -> Frame: frame = self.router.poll(dt) for action in frame.pressed: fn = self._handlers.get(action) if fn is not None: try: fn() except Exception as e: carb.log_error(f"[{self.name}] action {action} failed: {e}") if self.rig.mode == "orbit": d_yaw = -frame.value("orbit_yaw") * DRAG_RAD_PER_WINDOW # The stick moves the camera the way it is pushed: left takes the camera left # around the target (a positive yaw moves it right on screen), up raises it. # The mouse drags the scene instead, so its signs are the opposite. d_yaw += frame.amount("orbit_yaw_rate", STICK_RAD_PER_S) d_elev = frame.value("orbit_pitch") * DRAG_RAD_PER_WINDOW d_elev += frame.amount("orbit_pitch_rate", STICK_RAD_PER_S) # stick up: camera up log_r = -frame.value("orbit_range") * WHEEL_LOG_STEP log_r += frame.amount("orbit_range_rate", TRIGGER_LOG_PER_S) if d_yaw or d_elev or log_r: self.rig.orbit_by(d_yaw, d_elev, math.exp(log_r)) self.rig.update(dt) self._update_overlay() return frame
_HINTS = { "free": "K / X: attach", "chase": "T / D-pad down: next view Home / A: reset view", "pov": "T / D-pad down: next view", "orbit": "Right stick, drag: orbit Triggers, wheel: distance " "T / D-pad down: chase from here Home / A: reset", }
[docs] def view_badge(self) -> tuple: """(mode, title, hint) for the current view, e.g. ("orbit", "ORBIT 101", "Right stick, drag: orbit ..."). The built-in overlay draws it; an app with its own HUD can pass overlay=False and draw this at the top of its panel instead. COLORS in vantagekit.kit.overlay holds the color per mode.""" mode = self.rig.mode title = NAMES.get(mode, mode.upper()) if self.rig.attached and self.targets.selected is not None: title += f" {self.targets.selected}" if self._overlay_extra is not None: try: extra = self._overlay_extra() except Exception: extra = "" if extra: title += f" {extra}" return mode, title, self._HINTS.get(mode, "")
def _update_overlay(self) -> None: if not self._overlay_on: return if self._overlay is None: # the viewport window may not exist yet at startup: try about once a second now = time.monotonic() if now < self._overlay_retry_t: return self._overlay_retry_t = now + 1.0 self._overlay = ViewOverlay(f"{self.name}.view") if not self._overlay.ready: self._overlay = None return self._overlay.show(*self.view_badge())
[docs] def on_stage_opened(self) -> None: self.rig.reset_stage() self.state.set_layer("orbit", False)
[docs] def help_rows(self) -> List[dict]: return self.state.table()
[docs] def dispose(self) -> None: if self._overlay is not None: self._overlay.destroy() self._overlay = None self.router.dispose() self.rig.dispose() self._handlers.clear() self._mode_listeners.clear() self._target_listeners.clear()