vantagekit 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ """Targets the camera can attach to, and selection among them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Hashable, List, Optional, Protocol, Sequence
7
+
8
+ from .frame import Quat, Vec3, dot, sub
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Pose:
13
+ position: Vec3 # stage units, world space
14
+ orientation: Quat = (1.0, 0.0, 0.0, 0.0) # (w, x, y, z)
15
+
16
+
17
+ class TargetProvider(Protocol):
18
+ def target_ids(self) -> Sequence[Hashable]:
19
+ """Targets in cycling order."""
20
+
21
+ def target_pose(self, tid: Hashable) -> Optional[Pose]:
22
+ """World pose of a target, or None if it is not present now."""
23
+
24
+
25
+ class CallbackTargets:
26
+ """A TargetProvider from two callables, for apps that keep poses in their own dicts."""
27
+
28
+ def __init__(
29
+ self,
30
+ ids: Callable[[], Sequence[Hashable]],
31
+ pose: Callable[[Hashable], Optional[Pose]],
32
+ ):
33
+ self._ids = ids
34
+ self._pose = pose
35
+
36
+ def target_ids(self) -> Sequence[Hashable]:
37
+ return list(self._ids() or ())
38
+
39
+ def target_pose(self, tid: Hashable) -> Optional[Pose]:
40
+ return self._pose(tid)
41
+
42
+
43
+ class TargetCycler:
44
+ """The selected target. The selection survives targets coming and going; previous and
45
+ next step through the provider's current list."""
46
+
47
+ def __init__(self, provider: TargetProvider, selected: Hashable = None):
48
+ self.provider = provider
49
+ self.selected = selected
50
+
51
+ def ids(self) -> List[Hashable]:
52
+ return list(self.provider.target_ids())
53
+
54
+ def pose(self) -> Optional[Pose]:
55
+ if self.selected is None:
56
+ return None
57
+ return self.provider.target_pose(self.selected)
58
+
59
+ def step(self, direction: int) -> Hashable:
60
+ ids = self.ids()
61
+ if not ids:
62
+ return self.selected
63
+ if self.selected in ids:
64
+ i = (ids.index(self.selected) + direction) % len(ids)
65
+ else:
66
+ i = 0 if direction >= 0 else len(ids) - 1
67
+ self.selected = ids[i]
68
+ return self.selected
69
+
70
+ def nearest(self, point: Sequence[float]) -> Hashable:
71
+ best, best_d = None, None
72
+ for tid in self.ids():
73
+ p = self.provider.target_pose(tid)
74
+ if p is None:
75
+ continue
76
+ d = sub(p.position, point)
77
+ d2 = dot(d, d)
78
+ if best_d is None or d2 < best_d:
79
+ best, best_d = tid, d2
80
+ return best
81
+
82
+ def ensure(self, near: Sequence[float] | None = None) -> Hashable:
83
+ """Keep the selection if it is present; otherwise pick the target nearest to near
84
+ (or the first one)."""
85
+ if self.selected is not None and self.provider.target_pose(self.selected) is not None:
86
+ return self.selected
87
+ pick = self.nearest(near) if near is not None else None
88
+ if pick is None:
89
+ ids = self.ids()
90
+ pick = ids[0] if ids else None
91
+ if pick is not None:
92
+ self.selected = pick
93
+ return self.selected
@@ -0,0 +1,20 @@
1
+ """Kit adapters for the vantagekit core: camera rig, input router, native navigation
2
+ handover and Events 2.0 subscriptions. Loaded by Kit as the python module of the
3
+ lhzn.vantagekit extension."""
4
+
5
+ from .controls import ViewerControls
6
+ from .events import on_stage_opened, on_update
7
+ from .input import InputRouter
8
+ from .native_nav import NativeNavigation
9
+ from .rig import MODES, CameraRig, RigSettings
10
+
11
+ __all__ = [
12
+ "CameraRig",
13
+ "InputRouter",
14
+ "MODES",
15
+ "NativeNavigation",
16
+ "RigSettings",
17
+ "ViewerControls",
18
+ "on_stage_opened",
19
+ "on_update",
20
+ ]
@@ -0,0 +1,202 @@
1
+ """ViewerControls: the rig, the input router and target selection wired with the default
2
+ actions. Apps add handlers for their own actions and their own binding layers.
3
+
4
+ controls = ViewerControls("/World/ViewerCam", CallbackTargets(ids_fn, pose_fn))
5
+ controls.on("pilot_toggle", my_toggle)
6
+ ...
7
+ # once per frame, from the app's update observer:
8
+ frame = controls.update(dt)
9
+ ...
10
+ controls.dispose()
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import time
17
+ from typing import Callable, Dict, Iterable, List, Sequence
18
+
19
+ import carb
20
+
21
+ from ..core.bindings import DEFAULT_BINDINGS, Binding, BindingState, Frame
22
+ from ..core.targets import TargetCycler, TargetProvider
23
+ from .input import InputRouter
24
+ from .native_nav import NativeNavigation
25
+ from .overlay import NAMES, ViewOverlay
26
+ from .rig import CameraRig, RigSettings
27
+
28
+ # Orbit input scales: a drag across the whole window turns half a circle; a wheel step
29
+ # changes the range by about 14 percent; full stick deflection turns 90 degrees a second;
30
+ # a full trigger doubles or halves the range in about half a second.
31
+ DRAG_RAD_PER_WINDOW = math.pi
32
+ WHEEL_LOG_STEP = 0.15
33
+ STICK_RAD_PER_S = math.radians(90.0)
34
+ TRIGGER_LOG_PER_S = 1.5
35
+
36
+
37
+ class ViewerControls:
38
+ def __init__(
39
+ self,
40
+ camera_path: str,
41
+ provider: TargetProvider,
42
+ bindings: Iterable[Binding] | None = None,
43
+ layers: Sequence[str] = ("camera", "orbit"),
44
+ settings: RigSettings | None = None,
45
+ free_gamepad: bool | None = None,
46
+ order: int = 0,
47
+ consume: bool = False,
48
+ name: str = "vantagekit",
49
+ overlay: bool = True,
50
+ overlay_extra: Callable[[], str] | None = None,
51
+ ):
52
+ """overlay: draw the view badge over the viewport (an app with its own HUD can turn
53
+ it off and draw view_badge() itself). overlay_extra: returns text the app appends
54
+ to the badge title (for example "PILOT" while piloting)."""
55
+ self.name = name
56
+ self._overlay_on = overlay
57
+ self._overlay = None
58
+ self._overlay_retry_t = 0.0
59
+ self._overlay_extra = overlay_extra
60
+ self.targets = TargetCycler(provider)
61
+ self.native = NativeNavigation(free_gamepad=free_gamepad)
62
+ self.rig = CameraRig(camera_path, self.targets, settings, self.native, name=name)
63
+ self.state = BindingState(list(bindings) if bindings is not None else DEFAULT_BINDINGS, layers)
64
+ self.router = InputRouter(self.state, order=order, consume=consume, name=name)
65
+ self._handlers: Dict[str, Callable[[], None]] = {
66
+ "attach": self.toggle_attach,
67
+ "view_next": self.next_view,
68
+ "target_prev": lambda: self.step_target(-1),
69
+ "target_next": lambda: self.step_target(1),
70
+ "home": self.rig.home,
71
+ }
72
+ self._mode_listeners: List[Callable[[str], None]] = []
73
+ self._target_listeners: List[Callable[[object], None]] = []
74
+
75
+ # wiring ---------------------------------------------------------------------
76
+ def on(self, action: str, fn: Callable[[], None]) -> None:
77
+ """Handle a press action (replaces a default handler of the same name)."""
78
+ self._handlers[action] = fn
79
+
80
+ def on_mode_change(self, fn: Callable[[str], None]) -> None:
81
+ self._mode_listeners.append(fn)
82
+
83
+ def on_target_change(self, fn: Callable[[object], None]) -> None:
84
+ self._target_listeners.append(fn)
85
+
86
+ def set_layer(self, layer: str, on: bool) -> None:
87
+ self.state.set_layer(layer, on)
88
+
89
+ # actions ------------------------------------------------------------------
90
+ def set_mode(self, mode: str) -> str:
91
+ before = self.rig.mode
92
+ after = self.rig.set_mode(mode)
93
+ self.state.set_layer("orbit", after == "orbit")
94
+ if after != before:
95
+ for fn in self._mode_listeners:
96
+ fn(after)
97
+ return after
98
+
99
+ def toggle_attach(self) -> str:
100
+ return self.set_mode("free" if self.rig.attached else self.rig.view)
101
+
102
+ def next_view(self) -> str:
103
+ if not self.rig.attached:
104
+ return self.set_mode(self.rig.view)
105
+ views = list(self.rig.settings.views)
106
+ i = views.index(self.rig.mode) if self.rig.mode in views else -1
107
+ return self.set_mode(views[(i + 1) % len(views)])
108
+
109
+ def step_target(self, direction: int):
110
+ tid = self.rig.step_target(direction)
111
+ for fn in self._target_listeners:
112
+ fn(tid)
113
+ return tid
114
+
115
+ def select(self, tid) -> None:
116
+ self.targets.selected = tid
117
+ for fn in self._target_listeners:
118
+ fn(tid)
119
+
120
+ # per frame ------------------------------------------------------------------
121
+ def update(self, dt: float) -> Frame:
122
+ frame = self.router.poll(dt)
123
+ for action in frame.pressed:
124
+ fn = self._handlers.get(action)
125
+ if fn is not None:
126
+ try:
127
+ fn()
128
+ except Exception as e:
129
+ carb.log_error(f"[{self.name}] action {action} failed: {e}")
130
+ if self.rig.mode == "orbit":
131
+ d_yaw = -frame.value("orbit_yaw") * DRAG_RAD_PER_WINDOW
132
+ # The stick moves the camera the way it is pushed: left takes the camera left
133
+ # around the target (a positive yaw moves it right on screen), up raises it.
134
+ # The mouse drags the scene instead, so its signs are the opposite.
135
+ d_yaw += frame.amount("orbit_yaw_rate", STICK_RAD_PER_S)
136
+ d_elev = frame.value("orbit_pitch") * DRAG_RAD_PER_WINDOW
137
+ d_elev += frame.amount("orbit_pitch_rate", STICK_RAD_PER_S) # stick up: camera up
138
+ log_r = -frame.value("orbit_range") * WHEEL_LOG_STEP
139
+ log_r += frame.amount("orbit_range_rate", TRIGGER_LOG_PER_S)
140
+ if d_yaw or d_elev or log_r:
141
+ self.rig.orbit_by(d_yaw, d_elev, math.exp(log_r))
142
+ self.rig.update(dt)
143
+ self._update_overlay()
144
+ return frame
145
+
146
+ _HINTS = {
147
+ "free": "K / X: attach",
148
+ "chase": "T / D-pad down: next view Home / A: reset view",
149
+ "pov": "T / D-pad down: next view",
150
+ "orbit": "Right stick, drag: orbit Triggers, wheel: distance "
151
+ "T / D-pad down: chase from here Home / A: reset",
152
+ }
153
+
154
+ def view_badge(self) -> tuple:
155
+ """(mode, title, hint) for the current view, e.g. ("orbit", "ORBIT 101", "Right
156
+ stick, drag: orbit ..."). The built-in overlay draws it; an app with its own HUD
157
+ can pass overlay=False and draw this at the top of its panel instead. COLORS in
158
+ vantagekit.kit.overlay holds the color per mode."""
159
+ mode = self.rig.mode
160
+ title = NAMES.get(mode, mode.upper())
161
+ if self.rig.attached and self.targets.selected is not None:
162
+ title += f" {self.targets.selected}"
163
+ if self._overlay_extra is not None:
164
+ try:
165
+ extra = self._overlay_extra()
166
+ except Exception:
167
+ extra = ""
168
+ if extra:
169
+ title += f" {extra}"
170
+ return mode, title, self._HINTS.get(mode, "")
171
+
172
+ def _update_overlay(self) -> None:
173
+ if not self._overlay_on:
174
+ return
175
+ if self._overlay is None:
176
+ # the viewport window may not exist yet at startup: try about once a second
177
+ now = time.monotonic()
178
+ if now < self._overlay_retry_t:
179
+ return
180
+ self._overlay_retry_t = now + 1.0
181
+ self._overlay = ViewOverlay(f"{self.name}.view")
182
+ if not self._overlay.ready:
183
+ self._overlay = None
184
+ return
185
+ self._overlay.show(*self.view_badge())
186
+
187
+ def on_stage_opened(self) -> None:
188
+ self.rig.reset_stage()
189
+ self.state.set_layer("orbit", False)
190
+
191
+ def help_rows(self) -> List[dict]:
192
+ return self.state.table()
193
+
194
+ def dispose(self) -> None:
195
+ if self._overlay is not None:
196
+ self._overlay.destroy()
197
+ self._overlay = None
198
+ self.router.dispose()
199
+ self.rig.dispose()
200
+ self._handlers.clear()
201
+ self._mode_listeners.clear()
202
+ self._target_listeners.clear()
@@ -0,0 +1,53 @@
1
+ """App update and stage-opened subscriptions through Events 2.0 (carb.eventdispatcher).
2
+
3
+ Kit 110 logs deprecation warnings for get_update_event_stream and get_stage_event_stream.
4
+ Both helpers return an observer that unsubscribes when released (set it to None), and fall
5
+ back to the older streams on a Kit without Events 2.0.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable
11
+
12
+ import omni.kit.app
13
+ import omni.usd
14
+
15
+
16
+ def _dispatcher():
17
+ try:
18
+ import carb.eventdispatcher
19
+
20
+ return carb.eventdispatcher.get_eventdispatcher()
21
+ except Exception:
22
+ return None
23
+
24
+
25
+ def on_update(fn: Callable[[float], None], name: str) -> Any:
26
+ """Call fn(dt) once per app update."""
27
+ disp = _dispatcher()
28
+ if disp is not None and hasattr(omni.kit.app, "GLOBAL_EVENT_UPDATE"):
29
+ return disp.observe_event(
30
+ observer_name=name,
31
+ event_name=omni.kit.app.GLOBAL_EVENT_UPDATE,
32
+ on_event=lambda e: fn(float(e["dt"])),
33
+ )
34
+ stream = omni.kit.app.get_app().get_update_event_stream()
35
+ return stream.create_subscription_to_pop(lambda e: fn(float(e.payload["dt"])), name=name)
36
+
37
+
38
+ def on_stage_opened(fn: Callable[[], None], name: str, usd_context=None) -> Any:
39
+ """Call fn() each time a stage finishes opening."""
40
+ ctx = usd_context or omni.usd.get_context()
41
+ disp = _dispatcher()
42
+ if disp is not None and hasattr(ctx, "stage_event_name"):
43
+ return disp.observe_event(
44
+ observer_name=name,
45
+ event_name=ctx.stage_event_name(omni.usd.StageEventType.OPENED),
46
+ on_event=lambda e: fn(),
47
+ )
48
+
49
+ def legacy(e):
50
+ if e.type == int(omni.usd.StageEventType.OPENED):
51
+ fn()
52
+
53
+ return ctx.get_stage_event_stream().create_subscription_to_pop(legacy, name=name)
@@ -0,0 +1,152 @@
1
+ """carb.input adapter: feeds a BindingState from keyboard, mouse and gamepad.
2
+
3
+ Kit 110 contract (carb/input/InputTypes.h, OnInputEventFn): the callback returns whether
4
+ the event should be processed by subsequent subscribers, so True passes it on and False
5
+ consumes it. The router passes everything on by default and leaves native navigation to
6
+ NativeNavigation; with consume=True it consumes the keys and mouse inputs that an active
7
+ layer binds.
8
+
9
+ The gamepad is polled once per frame (carb gamepads do not repeat, and polling gives the
10
+ analog levels); gamepad input events are ignored so nothing is counted twice.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import carb
16
+ import carb.input
17
+
18
+ from ..core.bindings import BindingState, Frame
19
+
20
+ _STICK_AXES = {
21
+ "LEFT_STICK_X": ("LEFT_STICK_RIGHT", "LEFT_STICK_LEFT"),
22
+ "LEFT_STICK_Y": ("LEFT_STICK_UP", "LEFT_STICK_DOWN"),
23
+ "RIGHT_STICK_X": ("RIGHT_STICK_RIGHT", "RIGHT_STICK_LEFT"),
24
+ "RIGHT_STICK_Y": ("RIGHT_STICK_UP", "RIGHT_STICK_DOWN"),
25
+ }
26
+
27
+
28
+ def _mods(flags: int) -> list:
29
+ out = []
30
+ ci = carb.input
31
+ if flags & int(getattr(ci, "KEYBOARD_MODIFIER_FLAG_CONTROL", 0)):
32
+ out.append("ctrl")
33
+ if flags & int(getattr(ci, "KEYBOARD_MODIFIER_FLAG_SHIFT", 0)):
34
+ out.append("shift")
35
+ if flags & int(getattr(ci, "KEYBOARD_MODIFIER_FLAG_ALT", 0)):
36
+ out.append("alt")
37
+ return out
38
+
39
+
40
+ class InputRouter:
41
+ def __init__(self, state: BindingState, order: int = 0, consume: bool = False, name: str = "vantagekit"):
42
+ self.state = state
43
+ self.name = name
44
+ self._consume = consume
45
+ self._input = carb.input.acquire_input_interface()
46
+ self._keys = {}
47
+ for inp in state.inputs("key"):
48
+ enum = getattr(carb.input.KeyboardInput, inp.split(":", 1)[1], None)
49
+ if enum is None:
50
+ carb.log_warn(f"[{name}] unknown key in bindings: {inp}")
51
+ else:
52
+ self._keys[enum] = inp
53
+ self._pad_inputs = {}
54
+ for inp in state.inputs("pad"):
55
+ key = inp.split(":", 1)[1]
56
+ if key in _STICK_AXES:
57
+ pos, neg = _STICK_AXES[key]
58
+ self._pad_inputs[inp] = (
59
+ getattr(carb.input.GamepadInput, pos),
60
+ getattr(carb.input.GamepadInput, neg),
61
+ )
62
+ else:
63
+ enum = getattr(carb.input.GamepadInput, key, None)
64
+ if enum is None:
65
+ carb.log_warn(f"[{name}] unknown gamepad input in bindings: {inp}")
66
+ else:
67
+ self._pad_inputs[inp] = (enum, None)
68
+ self._pads = []
69
+ self._drag_from = None
70
+ # Optional callable: mouse events count only while it returns True. carb input is
71
+ # app-wide, so a viewer with its own pop-up (a second view with its own orbit) sets
72
+ # this to ignore drags and the wheel while the pointer is over that pop-up.
73
+ self.mouse_gate = None
74
+ self._sub = self._input.subscribe_to_input_events(self._on_event, order=order)
75
+ self._pad_sub = self._input.subscribe_to_gamepad_connection_events(self._on_pad)
76
+
77
+ # events -------------------------------------------------------------------
78
+ def _on_pad(self, e) -> None:
79
+ if e.type == carb.input.GamepadConnectionEventType.CREATED:
80
+ if e.gamepad not in self._pads:
81
+ self._pads.append(e.gamepad)
82
+ carb.log_info(f"[{self.name}] gamepad connected: {self._input.get_gamepad_name(e.gamepad)}")
83
+ elif e.type == carb.input.GamepadConnectionEventType.DESTROYED:
84
+ if e.gamepad in self._pads:
85
+ self._pads.remove(e.gamepad)
86
+ self.state.release_all()
87
+ carb.log_info(f"[{self.name}] gamepad disconnected")
88
+
89
+ def _on_event(self, event) -> bool:
90
+ dt = event.deviceType
91
+ if dt == carb.input.DeviceType.KEYBOARD:
92
+ return self._on_key(event.event)
93
+ if dt == carb.input.DeviceType.MOUSE:
94
+ return self._on_mouse(event.event)
95
+ return True # gamepad: polled in poll()
96
+
97
+ def _on_key(self, e) -> bool:
98
+ inp = self._keys.get(e.input)
99
+ if inp is None:
100
+ return True
101
+ t = e.type
102
+ if t == carb.input.KeyboardEventType.KEY_PRESS:
103
+ self.state.key_down(inp, _mods(int(e.modifiers)))
104
+ elif t == carb.input.KeyboardEventType.KEY_RELEASE:
105
+ self.state.key_up(inp)
106
+ # KEY_REPEAT and CHAR: ignored; a held key fires its press once
107
+ return not (self._consume and self.state.claims(inp))
108
+
109
+ def _on_mouse(self, e) -> bool:
110
+ if self.mouse_gate is not None and not self.mouse_gate():
111
+ self._drag_from = None # a drag that wanders into the pop-up ends there
112
+ return True
113
+ t = e.type
114
+ mt = carb.input.MouseEventType
115
+ claimed = False
116
+ if t == mt.LEFT_BUTTON_DOWN:
117
+ self._drag_from = (e.normalized_coords.x, e.normalized_coords.y)
118
+ claimed = self.state.claims("mouse:DRAG_X") or self.state.claims("mouse:DRAG_Y")
119
+ elif t == mt.LEFT_BUTTON_UP:
120
+ self._drag_from = None
121
+ elif t == mt.MOVE and self._drag_from is not None:
122
+ x, y = e.normalized_coords.x, e.normalized_coords.y
123
+ self.state.add_delta("mouse:DRAG_X", x - self._drag_from[0])
124
+ self.state.add_delta("mouse:DRAG_Y", y - self._drag_from[1])
125
+ self._drag_from = (x, y)
126
+ claimed = self.state.claims("mouse:DRAG_X") or self.state.claims("mouse:DRAG_Y")
127
+ elif t == mt.SCROLL:
128
+ self.state.add_delta("mouse:WHEEL", e.scrollDelta.y)
129
+ claimed = self.state.claims("mouse:WHEEL")
130
+ return not (self._consume and claimed)
131
+
132
+ # per frame ----------------------------------------------------------------
133
+ def poll(self, dt: float) -> Frame:
134
+ if self._pads:
135
+ gp = self._pads[0]
136
+ get = self._input.get_gamepad_value
137
+ for inp, (pos, neg) in self._pad_inputs.items():
138
+ v = float(get(gp, pos))
139
+ if neg is not None:
140
+ v -= float(get(gp, neg))
141
+ self.state.set_level(inp, v)
142
+ return self.state.step(dt)
143
+
144
+ def dispose(self) -> None:
145
+ if self._sub is not None:
146
+ self._input.unsubscribe_to_input_events(self._sub)
147
+ self._sub = None
148
+ if self._pad_sub is not None:
149
+ self._input.unsubscribe_to_gamepad_connection_events(self._pad_sub)
150
+ self._pad_sub = None
151
+ self._pads = []
152
+ self.state.release_all()
@@ -0,0 +1,84 @@
1
+ """Hand camera navigation between Kit and the rig, only when the mode changes.
2
+
3
+ Kit 110 has two independent native controllers:
4
+
5
+ - Mouse and keyboard navigation (omni.kit.manipulator.camera): at the start of every
6
+ gesture it reads the camera prim's `omni:kit:cameraLock` attribute and, when it is true,
7
+ disables tumble, look, pan, zoom and fly for that camera. Locking the rig's camera is a
8
+ per-camera switch that leaves other viewports and cameras alone.
9
+ - Gamepad navigation: enabled by the persistent setting
10
+ `/persistent/app/omniverse/gamepadCameraControl`, which the viewport window watches.
11
+ Isaac Sim's app file sets it false. It is persistent (saved in the user config), so the
12
+ value found at startup is restored on dispose.
13
+
14
+ Nothing here runs per frame.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import carb
20
+ import carb.settings
21
+ from pxr import Sdf
22
+
23
+ CAMERA_LOCK_ATTR = "omni:kit:cameraLock"
24
+ GAMEPAD_SETTING = "/persistent/app/omniverse/gamepadCameraControl"
25
+
26
+
27
+ class NativeNavigation:
28
+ """free_gamepad: native gamepad navigation in free mode; None keeps the value found at
29
+ startup (the app may want it off so the sticks stay with its own controls)."""
30
+
31
+ def __init__(self, free_gamepad: bool | None = None):
32
+ self._settings = carb.settings.get_settings()
33
+ self._saved_gamepad = self._settings.get(GAMEPAD_SETTING)
34
+ self._free_gamepad = free_gamepad
35
+ self._locked: set = set() # (stage id, path) we locked
36
+ self._native: bool | None = None
37
+
38
+ def _gamepad_value(self, native: bool) -> bool:
39
+ if not native:
40
+ return False
41
+ if self._free_gamepad is not None:
42
+ return bool(self._free_gamepad)
43
+ return bool(self._saved_gamepad)
44
+
45
+ def set_native(self, native: bool, stage=None, camera_path: str | None = None) -> None:
46
+ """native=True gives the camera to Kit (free mode); False gives it to the rig."""
47
+ if self._native == native:
48
+ return
49
+ self._native = native
50
+ want = self._gamepad_value(native)
51
+ if bool(self._settings.get(GAMEPAD_SETTING)) != want:
52
+ self._settings.set_bool(GAMEPAD_SETTING, want)
53
+ if stage is not None and camera_path:
54
+ self._lock(stage, camera_path, not native)
55
+
56
+ def _lock(self, stage, camera_path: str, on: bool) -> None:
57
+ prim = stage.GetPrimAtPath(camera_path)
58
+ if not prim or not prim.IsValid():
59
+ return
60
+ key = (id(stage), str(camera_path))
61
+ if on:
62
+ attr = prim.CreateAttribute(CAMERA_LOCK_ATTR, Sdf.ValueTypeNames.Bool, custom=True)
63
+ attr.Set(True)
64
+ self._locked.add(key)
65
+ elif key in self._locked or prim.GetAttribute(CAMERA_LOCK_ATTR):
66
+ attr = prim.GetAttribute(CAMERA_LOCK_ATTR)
67
+ if attr:
68
+ attr.Set(False)
69
+ self._locked.discard(key)
70
+
71
+ def reset(self) -> None:
72
+ """Forget the applied state (a new stage was opened); the next set_native applies."""
73
+ self._native = None
74
+ self._locked.clear()
75
+
76
+ def dispose(self, stage=None, camera_path: str | None = None) -> None:
77
+ if stage is not None and camera_path:
78
+ try:
79
+ self._lock(stage, camera_path, False)
80
+ except Exception as e: # stage may be closing
81
+ carb.log_warn(f"[vantagekit] could not unlock {camera_path}: {e}")
82
+ if self._saved_gamepad is not None:
83
+ self._settings.set_bool(GAMEPAD_SETTING, bool(self._saved_gamepad))
84
+ self._native = None