Source code for vantagekit.kit.rig

"""CameraRig: drives one USD camera in free, chase, point-of-view and orbit modes.

The rig reads the stage up axis and meters per unit, so the same settings (meters and
seconds) work on Y-up and Z-up stages in any unit. In free mode it writes nothing and Kit's
native navigation moves the camera; in the attached modes it writes one cached
xformOp:transform per frame and the camera is locked against native navigation
(NativeNavigation). Mode changes are the only time settings or op order change.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple

import carb
import omni.usd
from pxr import Gf, Usd, UsdGeom

from ..core.frame import UpFrame, Vec3, add, scale
from ..core.rig_math import (
    chase_orbit_view,
    chase_perspective,
    look_at_matrix,
    orbit_eye,
    orbit_from_eye,
    pov_view,
)
from ..core.smoothing import Smoothed3, SmoothedAngle, wrap_angle
from ..core.targets import TargetCycler
from .native_nav import NativeNavigation

MODES = ("free", "chase", "pov", "orbit")


[docs] @dataclass class RigSettings: views: Tuple[str, ...] = ("chase", "pov", "orbit") body_forward: Optional[Sequence[float]] = None # target's forward axis; None: +Z (Y up), +X (Z up) # chase: the default perspective, behind and above, horizon level. The camera looks # at the pivot chase_look_up_m above the target; orbit turns around the same pivot, # and leaving orbit for chase keeps the perspective set there. chase_back_m: float = 12.5 chase_up_m: float = 1.8 chase_look_up_m: float = 1.8 chase_tau_target_s: float = 0.05 chase_tau_heading_s: float = 0.35 # point of view: fixed in the target's body frame pov_forward_m: float = 6.0 pov_up_m: float = 2.5 pov_right_m: float = 0.0 # orbit orbit_tau_target_s: float = 0.15 orbit_range_m: float = 40.0 orbit_min_m: float = 2.0 orbit_max_m: float = 20000.0 orbit_elevation_deg: float = 20.0 orbit_min_elevation_deg: float = -10.0 orbit_max_elevation_deg: float = 85.0
[docs] class CameraRig: def __init__( self, camera_path: str, targets: TargetCycler, settings: RigSettings | None = None, native: NativeNavigation | None = None, usd_context=None, name: str = "vantagekit", ): self.camera_path = str(camera_path) self.targets = targets self.settings = settings or RigSettings() self.native = native or NativeNavigation() self.name = name self._ctx = usd_context or omni.usd.get_context() self.mode = "free" self.view = self.settings.views[0] self.frame = UpFrame("Y") self._op = None self._target = Smoothed3(self.settings.chase_tau_target_s) self._heading = SmoothedAngle(self.settings.chase_tau_heading_s) self._last_heading = 0.0 self.orbit_yaw = 0.0 self.orbit_elevation = math.radians(self.settings.orbit_elevation_deg) self.orbit_range_m = self.settings.orbit_range_m # Chase perspective, relative to the target's heading: (yaw, elevation, meters). # Leaving orbit for chase keeps the perspective set in orbit; home() resets it. self.chase_yaw_rel, self.chase_elevation, self.chase_range_m = self.default_chase() self.home_view: Optional[Tuple[Vec3, Vec3]] = None # free-mode home (eye, look)
[docs] def default_chase(self) -> Tuple[float, float, float]: s = self.settings return chase_perspective(s.chase_back_m, s.chase_up_m, s.chase_look_up_m)
# stage ---------------------------------------------------------------------- @property def stage(self) -> Usd.Stage | None: return self._ctx.get_stage()
[docs] def refresh_frame(self) -> UpFrame: stage = self.stage if stage is not None: self.frame = UpFrame( str(UsdGeom.GetStageUpAxis(stage)), UsdGeom.GetStageMetersPerUnit(stage), self.settings.body_forward, ) return self.frame
[docs] def reset_stage(self) -> None: """Call after a stage opens: drops cached ops and returns to free mode.""" self._op = None self.mode = "free" self.native.reset() self.refresh_frame() self.native.set_native(True, self.stage, self.camera_path)
def _prim(self): stage = self.stage return stage.GetPrimAtPath(self.camera_path) if stage else None
[docs] def eye(self) -> Vec3 | None: prim = self._prim() if not prim or not prim.IsValid(): return None t = UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(Usd.TimeCode.Default()).ExtractTranslation() return (t[0], t[1], t[2])
# modes ---------------------------------------------------------------------- @property def attached(self) -> bool: return self.mode != "free"
[docs] def set_mode(self, mode: str) -> str: if mode not in MODES: raise ValueError(f"mode must be one of {MODES}, not {mode!r}") if mode == self.mode: return self.mode stage = self.stage if mode != "free": prim = self._prim() if not prim or not prim.IsValid(): carb.log_warn(f"[{self.name}] no camera at {self.camera_path}; staying in {self.mode}") return self.mode if self.targets.ensure(near=self.eye()) is None: carb.log_warn(f"[{self.name}] no target to attach to; staying in {self.mode}") return self.mode self.refresh_frame() if self.mode == "free": self._prepare_op(prim) self._activate_camera() self.view = mode self._enter(mode, self.mode) self.mode = mode self.native.set_native(mode == "free", stage, self.camera_path) carb.log_info(f"[{self.name}] camera mode {mode} (target {self.targets.selected})") return self.mode
[docs] def step_target(self, direction: int): return self.targets.step(direction)
def _pivot(self, target: Sequence[float]) -> Vec3: """Chase and orbit both turn around this point above the target, so switching between them does not move the camera.""" f = self.frame return add(target, scale(f.up, f.to_units(self.settings.chase_look_up_m))) def _enter(self, mode: str, prev: str) -> None: s = self.settings pose = self.targets.pose() if pose is None: return # Keep the smoothed target and heading when moving between attached views if prev == "free" or self._target.value is None: self._target.snap(pose.position) h = self.frame.heading(pose.orientation) self._last_heading = h if h is not None else self._last_heading self._heading.snap(self._last_heading) self._target.tau = s.orbit_tau_target_s if mode == "orbit" else s.chase_tau_target_s heading = self._heading.value if self._heading.value is not None else self._last_heading # Chase and orbit share one vantage (angle from the heading, elevation, range). # Point of view is a fixed seat on the target and never changes it, so chase, pov, # orbit returns to the chase vantage instead of orbiting from the seat's eye (which # started the orbit a few meters out, 2026-11-08). if prev == "orbit" and mode in ("chase", "pov"): # keep the perspective set in orbit, now turning with the target's heading self.chase_yaw_rel = wrap_angle(self.orbit_yaw - heading) self.chase_elevation = self.orbit_elevation self.chase_range_m = self.orbit_range_m if mode == "orbit": if prev in ("chase", "pov"): # start the orbit at the chase vantage self.orbit_yaw = heading + self.chase_yaw_rel self._set_elevation(self.chase_elevation) self._set_range_m(self.chase_range_m) else: eye = self.eye() if eye is not None: yaw, elev, dist = orbit_from_eye(self._pivot(self._target.value), eye, self.frame) self.orbit_yaw = yaw self._set_elevation(elev) self._set_range_m(dist * self.frame.meters_per_unit)
[docs] def home(self) -> None: """Attached: back to the default chase perspective (behind the target), in chase and in orbit. Free: the saved home view, if any.""" if not self.attached: if self.home_view is not None: self.place(*self.home_view) return self.chase_yaw_rel, self.chase_elevation, self.chase_range_m = self.default_chase() heading = self._heading.value if self._heading.value is not None else self._last_heading self.orbit_yaw = heading + self.chase_yaw_rel self._set_elevation(self.chase_elevation) self._set_range_m(self.chase_range_m)
# orbit input ------------------------------------------------------------------ def _set_elevation(self, e: float) -> None: lo = math.radians(self.settings.orbit_min_elevation_deg) hi = math.radians(self.settings.orbit_max_elevation_deg) self.orbit_elevation = max(lo, min(hi, e)) def _set_range_m(self, r: float) -> None: self.orbit_range_m = max(self.settings.orbit_min_m, min(self.settings.orbit_max_m, r))
[docs] def orbit_by(self, d_yaw: float = 0.0, d_elevation: float = 0.0, range_factor: float = 1.0) -> None: self.orbit_yaw += d_yaw self._set_elevation(self.orbit_elevation + d_elevation) self._set_range_m(self.orbit_range_m * range_factor)
# per frame --------------------------------------------------------------------
[docs] def update(self, dt: float) -> bool: """Advance one frame; returns True when the camera was written.""" if not self.attached: return False pose = self.targets.pose() if pose is None: return False f = self.frame s = self.settings h = f.heading(pose.orientation) if h is not None: self._last_heading = h target = self._target.update(pose.position, dt) heading = self._heading.update(self._last_heading, dt) if self.mode == "chase": eye, look, up = chase_orbit_view( target, heading, self.chase_yaw_rel, self.chase_elevation, f.to_units(self.chase_range_m), f.to_units(s.chase_look_up_m), f, ) elif self.mode == "pov": eye, look, up = pov_view( pose.position, pose.orientation, f.to_units(s.pov_forward_m), f.to_units(s.pov_up_m), f.to_units(s.pov_right_m), f, ) else: # orbit look, up = self._pivot(target), f.up eye = orbit_eye(look, self.orbit_yaw, self.orbit_elevation, f.to_units(self.orbit_range_m), f) return self._write(look_at_matrix(eye, look, up))
[docs] def place(self, eye: Sequence[float], look: Sequence[float]) -> bool: """Put the camera at eye looking at look (stage units), in any mode.""" prim = self._prim() if not prim or not prim.IsValid(): return False if self._op is None or not self._op_valid(prim): self._prepare_op(prim) return self._write(look_at_matrix(eye, look, self.refresh_frame().up))
# USD ---------------------------------------------------------------------------- def _activate_camera(self) -> None: try: from omni.kit.viewport.utility import get_active_viewport vp = get_active_viewport() if vp is not None and str(vp.camera_path) != self.camera_path: vp.camera_path = self.camera_path except Exception as e: carb.log_warn(f"[{self.name}] could not switch the viewport camera: {e}") def _op_valid(self, prim) -> bool: ops = UsdGeom.Xformable(prim).GetOrderedXformOps() return len(ops) == 1 and ops[0].GetOpType() == UsdGeom.XformOp.TypeTransform def _prepare_op(self, prim) -> None: """One xformOp:transform holding the camera's current local transform, so taking over the camera does not move it.""" xf = UsdGeom.Xformable(prim) local = xf.GetLocalTransformation(Usd.TimeCode.Default()) if self._op_valid(prim): self._op = xf.GetOrderedXformOps()[0] return xf.ClearXformOpOrder() self._op = xf.AddTransformOp() self._op.Set(local) def _write(self, rows) -> bool: prim = self._prim() if not prim or not prim.IsValid(): return False if self._op is None or not self._op.GetAttr().IsValid(): self._prepare_op(prim) world = Gf.Matrix4d(*[c for row in rows for c in row]) parent = prim.GetParent() if parent and parent.IsValid() and not parent.IsPseudoRoot(): pw = UsdGeom.Xformable(parent).ComputeLocalToWorldTransform(Usd.TimeCode.Default()) world = world * pw.GetInverse() self._op.Set(world) return True
[docs] def status(self) -> dict: return { "mode": self.mode, "view": self.view, "target": self.targets.selected, "camera": self.camera_path, "up_axis": self.frame.axis, "meters_per_unit": self.frame.meters_per_unit, "eye": self.eye(), "orbit": { "yaw_deg": math.degrees(self.orbit_yaw), "elevation_deg": math.degrees(self.orbit_elevation), "range_m": self.orbit_range_m, }, "chase": { "yaw_rel_deg": math.degrees(self.chase_yaw_rel), "elevation_deg": math.degrees(self.chase_elevation), "range_m": self.chase_range_m, }, }
[docs] def dispose(self) -> None: self.native.dispose(self.stage, self.camera_path) self._op = None self.mode = "free"