vantagekit 0.6.1__tar.gz
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.
- vantagekit-0.6.1/.gitignore +5 -0
- vantagekit-0.6.1/LICENSE +21 -0
- vantagekit-0.6.1/PKG-INFO +84 -0
- vantagekit-0.6.1/kit/README.md +43 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/__init__.py +7 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/__init__.py +32 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/bindings.py +232 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/frame.py +123 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/rig_math.py +129 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/smoothing.py +126 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/targets.py +93 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/__init__.py +20 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/controls.py +202 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/events.py +53 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/input.py +152 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/native_nav.py +84 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/overlay.py +95 -0
- vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/rig.py +346 -0
- vantagekit-0.6.1/kit/pyproject.toml +5 -0
- vantagekit-0.6.1/kit/tests/test_bindings.py +150 -0
- vantagekit-0.6.1/kit/tests/test_rig_math.py +185 -0
- vantagekit-0.6.1/kit/tests/test_smoothing.py +128 -0
- vantagekit-0.6.1/kit/tests/test_targets.py +45 -0
- vantagekit-0.6.1/pyproject.toml +44 -0
vantagekit-0.6.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Long Horizon Observatory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vantagekit
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Camera and input controls for viewers of terrain, ocean and vessel scenes: orbit, chase and point-of-view camera math, frame-rate independent smoothing, and bindings as data, with Omniverse Kit adapters.
|
|
5
|
+
Project-URL: Demo, https://lhzn-io.github.io/vantagekit/demo/
|
|
6
|
+
Project-URL: Issues, https://github.com/lhzn-io/vantagekit/issues
|
|
7
|
+
Project-URL: Source, https://github.com/lhzn-io/vantagekit
|
|
8
|
+
Author-email: Long Horizon Observatory <dfry@lhzn.io>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Long Horizon Observatory
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: camera,gamepad,input,navigation,omniverse,viewer
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: Intended Audience :: Science/Research
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# vantagekit (Python)
|
|
43
|
+
|
|
44
|
+
Camera and input controls for viewers of terrain, ocean and vessel scenes. This package is the
|
|
45
|
+
Python half of [vantagekit](https://github.com/lhzn-io/vantagekit), whose JavaScript modules give
|
|
46
|
+
three.js and MapLibre GL JS viewers the same navigation in the browser
|
|
47
|
+
([live demo](https://lhzn-io.github.io/vantagekit/demo/)).
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install vantagekit
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## vantagekit.core
|
|
54
|
+
|
|
55
|
+
Standard library only, so it runs anywhere Python 3.10 or later does: in tests, in tools, or in a
|
|
56
|
+
viewer built on any renderer.
|
|
57
|
+
|
|
58
|
+
- **Stage frames** for Y-up and Z-up scenes in any unit (`UpFrame`), with headings and yaw from
|
|
59
|
+
orientations.
|
|
60
|
+
- **Camera math** for orbit, chase and point-of-view cameras around a moving target (`orbit_eye`,
|
|
61
|
+
`look_at_matrix`). Every distance is in metres and every time constant in seconds.
|
|
62
|
+
- **Smoothing** that does not depend on frame rate (`Smoothed3`, `SmoothedAngle`, `exp_alpha`):
|
|
63
|
+
the filter is solved exactly for a target moving steadily between frames, so the lag behind a
|
|
64
|
+
moving target is its speed times the time constant at any frame time.
|
|
65
|
+
- **Bindings as data** (`Binding`, `DEFAULT_BINDINGS`): press, hold, axis and delta kinds, exact
|
|
66
|
+
modifiers, and ordered layers, so an app layer can take the sticks from the camera while it is on.
|
|
67
|
+
- **Target selection** (`CallbackTargets`, `Pose`).
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from vantagekit.core import SmoothedAngle, UpFrame, orbit_eye
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## vantagekit.kit
|
|
74
|
+
|
|
75
|
+
Adapters for Omniverse Kit 109 and 110 (Isaac Sim 6): a camera rig with free, chase,
|
|
76
|
+
point-of-view and orbit modes, a handover to Kit's own navigation, a keyboard, mouse and gamepad
|
|
77
|
+
input router, and a view badge. These import `carb` and `omni` and so work only inside Kit. A Kit
|
|
78
|
+
app usually loads them as the `lhzn.vantagekit` extension from the repository's `kit/exts` folder;
|
|
79
|
+
see the [main README](https://github.com/lhzn-io/vantagekit#omniverse-kit).
|
|
80
|
+
|
|
81
|
+
## Versions
|
|
82
|
+
|
|
83
|
+
The package version follows the repository's release tags, so `vantagekit==0.6.1` matches the
|
|
84
|
+
JavaScript modules served from `@v0.6.1`. MIT licence, Long Horizon Observatory.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# vantagekit (Python)
|
|
2
|
+
|
|
3
|
+
Camera and input controls for viewers of terrain, ocean and vessel scenes. This package is the
|
|
4
|
+
Python half of [vantagekit](https://github.com/lhzn-io/vantagekit), whose JavaScript modules give
|
|
5
|
+
three.js and MapLibre GL JS viewers the same navigation in the browser
|
|
6
|
+
([live demo](https://lhzn-io.github.io/vantagekit/demo/)).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install vantagekit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## vantagekit.core
|
|
13
|
+
|
|
14
|
+
Standard library only, so it runs anywhere Python 3.10 or later does: in tests, in tools, or in a
|
|
15
|
+
viewer built on any renderer.
|
|
16
|
+
|
|
17
|
+
- **Stage frames** for Y-up and Z-up scenes in any unit (`UpFrame`), with headings and yaw from
|
|
18
|
+
orientations.
|
|
19
|
+
- **Camera math** for orbit, chase and point-of-view cameras around a moving target (`orbit_eye`,
|
|
20
|
+
`look_at_matrix`). Every distance is in metres and every time constant in seconds.
|
|
21
|
+
- **Smoothing** that does not depend on frame rate (`Smoothed3`, `SmoothedAngle`, `exp_alpha`):
|
|
22
|
+
the filter is solved exactly for a target moving steadily between frames, so the lag behind a
|
|
23
|
+
moving target is its speed times the time constant at any frame time.
|
|
24
|
+
- **Bindings as data** (`Binding`, `DEFAULT_BINDINGS`): press, hold, axis and delta kinds, exact
|
|
25
|
+
modifiers, and ordered layers, so an app layer can take the sticks from the camera while it is on.
|
|
26
|
+
- **Target selection** (`CallbackTargets`, `Pose`).
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from vantagekit.core import SmoothedAngle, UpFrame, orbit_eye
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## vantagekit.kit
|
|
33
|
+
|
|
34
|
+
Adapters for Omniverse Kit 109 and 110 (Isaac Sim 6): a camera rig with free, chase,
|
|
35
|
+
point-of-view and orbit modes, a handover to Kit's own navigation, a keyboard, mouse and gamepad
|
|
36
|
+
input router, and a view badge. These import `carb` and `omni` and so work only inside Kit. A Kit
|
|
37
|
+
app usually loads them as the `lhzn.vantagekit` extension from the repository's `kit/exts` folder;
|
|
38
|
+
see the [main README](https://github.com/lhzn-io/vantagekit#omniverse-kit).
|
|
39
|
+
|
|
40
|
+
## Versions
|
|
41
|
+
|
|
42
|
+
The package version follows the repository's release tags, so `vantagekit==0.6.1` matches the
|
|
43
|
+
JavaScript modules served from `@v0.6.1`. MIT licence, Long Horizon Observatory.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""vantagekit core for Kit viewers: frames, smoothing, camera math, bindings and targets.
|
|
2
|
+
|
|
3
|
+
Standard library only; nothing here imports Kit, so it is tested with plain pytest.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .bindings import DEFAULT_BINDINGS, Binding, BindingState, Frame
|
|
7
|
+
from .frame import UpFrame
|
|
8
|
+
from .rig_math import chase_view, look_at_matrix, orbit_eye, orbit_from_eye, pov_view
|
|
9
|
+
from .smoothing import Smoothed3, SmoothedAngle, exp_alpha, spring_step, wrap_angle
|
|
10
|
+
from .targets import CallbackTargets, Pose, TargetCycler, TargetProvider
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Binding",
|
|
14
|
+
"BindingState",
|
|
15
|
+
"CallbackTargets",
|
|
16
|
+
"DEFAULT_BINDINGS",
|
|
17
|
+
"Frame",
|
|
18
|
+
"Pose",
|
|
19
|
+
"Smoothed3",
|
|
20
|
+
"SmoothedAngle",
|
|
21
|
+
"TargetCycler",
|
|
22
|
+
"TargetProvider",
|
|
23
|
+
"UpFrame",
|
|
24
|
+
"chase_view",
|
|
25
|
+
"exp_alpha",
|
|
26
|
+
"look_at_matrix",
|
|
27
|
+
"orbit_eye",
|
|
28
|
+
"orbit_from_eye",
|
|
29
|
+
"pov_view",
|
|
30
|
+
"spring_step",
|
|
31
|
+
"wrap_angle",
|
|
32
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Bindings as data, and the state machine that turns raw input into actions.
|
|
2
|
+
|
|
3
|
+
Inputs are device-qualified names:
|
|
4
|
+
|
|
5
|
+
key:K key:LEFT_BRACKET keyboard (carb.input.KeyboardInput names)
|
|
6
|
+
pad:X pad:DPAD_DOWN gamepad buttons (carb.input.GamepadInput names)
|
|
7
|
+
pad:LEFT_STICK_Y pad:RIGHT_STICK_X gamepad axes (up minus down, right minus left)
|
|
8
|
+
pad:LEFT_TRIGGER pad:RIGHT_TRIGGER gamepad triggers, 0 to 1
|
|
9
|
+
mouse:WHEEL mouse:DRAG_X mouse:DRAG_Y mouse deltas (drag with the left button held)
|
|
10
|
+
|
|
11
|
+
Kinds:
|
|
12
|
+
|
|
13
|
+
press fires once on the down edge; repeats and releases never fire it; its modifiers
|
|
14
|
+
must match the held modifiers exactly
|
|
15
|
+
hold held or not this frame; the consumer integrates with dt
|
|
16
|
+
axis a polled level in [-1, 1] (or [0, 1]) with a deadzone
|
|
17
|
+
delta summed between frames (wheel steps, drag distance in normalized window units)
|
|
18
|
+
|
|
19
|
+
Layers are named and ordered (later is higher). An input belongs to the highest active
|
|
20
|
+
layer that binds it, so an app layer can take the sticks from the camera while it is on.
|
|
21
|
+
|
|
22
|
+
Pure Python: the Kit adapter (kit/input.py) feeds key_down, key_up, set_level and
|
|
23
|
+
add_delta, then calls step(dt) once per frame.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Dict, FrozenSet, Iterable, List, Sequence, Set
|
|
30
|
+
|
|
31
|
+
KINDS = ("press", "hold", "axis", "delta")
|
|
32
|
+
MODIFIERS = ("ctrl", "shift", "alt")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Binding:
|
|
37
|
+
action: str
|
|
38
|
+
input: str
|
|
39
|
+
kind: str = "press"
|
|
40
|
+
mods: FrozenSet[str] = frozenset()
|
|
41
|
+
layer: str = "camera"
|
|
42
|
+
scale: float = 1.0
|
|
43
|
+
deadzone: float = 0.1
|
|
44
|
+
label: str = ""
|
|
45
|
+
|
|
46
|
+
def __post_init__(self):
|
|
47
|
+
if self.kind not in KINDS:
|
|
48
|
+
raise ValueError(f"{self.action}: kind must be one of {KINDS}, not {self.kind!r}")
|
|
49
|
+
if ":" not in self.input:
|
|
50
|
+
raise ValueError(f"{self.action}: input {self.input!r} needs a device prefix")
|
|
51
|
+
bad = set(self.mods) - set(MODIFIERS)
|
|
52
|
+
if bad:
|
|
53
|
+
raise ValueError(f"{self.action}: unknown modifiers {sorted(bad)}")
|
|
54
|
+
object.__setattr__(self, "mods", frozenset(self.mods))
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def device(self) -> str:
|
|
58
|
+
return self.input.split(":", 1)[0]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def name(self) -> str:
|
|
62
|
+
return self.input.split(":", 1)[1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Frame:
|
|
67
|
+
"""What one step saw: fired presses in order, held actions, and axis and delta values."""
|
|
68
|
+
|
|
69
|
+
dt: float = 0.0
|
|
70
|
+
pressed: List[str] = field(default_factory=list)
|
|
71
|
+
held: Set[str] = field(default_factory=set)
|
|
72
|
+
values: Dict[str, float] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def value(self, action: str) -> float:
|
|
75
|
+
return self.values.get(action, 0.0)
|
|
76
|
+
|
|
77
|
+
def amount(self, action: str, rate: float) -> float:
|
|
78
|
+
"""How far a rate-driven action moves this frame: rate * dt when held, or scaled by
|
|
79
|
+
its axis value. Delta actions are already per frame and are returned as they are."""
|
|
80
|
+
if action in self.held:
|
|
81
|
+
return rate * self.dt
|
|
82
|
+
return self.values.get(action, 0.0) * rate * self.dt
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BindingState:
|
|
86
|
+
def __init__(self, bindings: Iterable[Binding], layers: Sequence[str] = ("camera",)):
|
|
87
|
+
self.bindings: List[Binding] = list(bindings)
|
|
88
|
+
self.layer_order: List[str] = list(layers)
|
|
89
|
+
for b in self.bindings:
|
|
90
|
+
if b.layer not in self.layer_order:
|
|
91
|
+
self.layer_order.append(b.layer)
|
|
92
|
+
self.active: Set[str] = {"camera"} & set(self.layer_order)
|
|
93
|
+
self._down: Set[str] = set()
|
|
94
|
+
self._levels: Dict[str, float] = {}
|
|
95
|
+
self._deltas: Dict[str, float] = {}
|
|
96
|
+
self._mods: FrozenSet[str] = frozenset()
|
|
97
|
+
self._pending: List[str] = []
|
|
98
|
+
|
|
99
|
+
# layers ------------------------------------------------------------------
|
|
100
|
+
def set_layer(self, layer: str, on: bool) -> None:
|
|
101
|
+
if layer not in self.layer_order:
|
|
102
|
+
self.layer_order.append(layer)
|
|
103
|
+
if on:
|
|
104
|
+
self.active.add(layer)
|
|
105
|
+
else:
|
|
106
|
+
self.active.discard(layer)
|
|
107
|
+
|
|
108
|
+
def owner(self, inp: str) -> str | None:
|
|
109
|
+
"""The highest active layer that binds inp, or None."""
|
|
110
|
+
best = None
|
|
111
|
+
best_rank = -1
|
|
112
|
+
for b in self.bindings:
|
|
113
|
+
if b.input == inp and b.layer in self.active:
|
|
114
|
+
rank = self.layer_order.index(b.layer)
|
|
115
|
+
if rank > best_rank:
|
|
116
|
+
best, best_rank = b.layer, rank
|
|
117
|
+
return best
|
|
118
|
+
|
|
119
|
+
def _live(self, inp: str) -> List[Binding]:
|
|
120
|
+
layer = self.owner(inp)
|
|
121
|
+
return [b for b in self.bindings if b.input == inp and b.layer == layer]
|
|
122
|
+
|
|
123
|
+
def claims(self, inp: str) -> bool:
|
|
124
|
+
return self.owner(inp) is not None
|
|
125
|
+
|
|
126
|
+
def inputs(self, device: str | None = None) -> List[str]:
|
|
127
|
+
seen = []
|
|
128
|
+
for b in self.bindings:
|
|
129
|
+
if (device is None or b.device == device) and b.input not in seen:
|
|
130
|
+
seen.append(b.input)
|
|
131
|
+
return seen
|
|
132
|
+
|
|
133
|
+
# raw input ----------------------------------------------------------------
|
|
134
|
+
def set_mods(self, mods: Iterable[str]) -> None:
|
|
135
|
+
self._mods = frozenset(m for m in mods if m in MODIFIERS)
|
|
136
|
+
|
|
137
|
+
def key_down(self, inp: str, mods: Iterable[str] | None = None) -> None:
|
|
138
|
+
"""A key or button went down. A repeat (already down) does nothing."""
|
|
139
|
+
if mods is not None:
|
|
140
|
+
self.set_mods(mods)
|
|
141
|
+
if inp in self._down:
|
|
142
|
+
return
|
|
143
|
+
self._down.add(inp)
|
|
144
|
+
for b in self._live(inp):
|
|
145
|
+
# Keyboard modifiers do not apply to gamepad buttons
|
|
146
|
+
if b.kind == "press" and (b.device == "pad" or b.mods == self._mods):
|
|
147
|
+
self._pending.append(b.action)
|
|
148
|
+
|
|
149
|
+
def key_up(self, inp: str) -> None:
|
|
150
|
+
self._down.discard(inp)
|
|
151
|
+
|
|
152
|
+
def set_level(self, inp: str, value: float, threshold: float = 0.5) -> None:
|
|
153
|
+
"""A polled value (pad button or axis). Buttons cross threshold to go down or up."""
|
|
154
|
+
value = float(value)
|
|
155
|
+
self._levels[inp] = value
|
|
156
|
+
if abs(value) >= threshold:
|
|
157
|
+
self.key_down(inp)
|
|
158
|
+
elif inp in self._down:
|
|
159
|
+
self.key_up(inp)
|
|
160
|
+
|
|
161
|
+
def add_delta(self, inp: str, d: float) -> None:
|
|
162
|
+
self._deltas[inp] = self._deltas.get(inp, 0.0) + float(d)
|
|
163
|
+
|
|
164
|
+
def release_all(self) -> None:
|
|
165
|
+
"""Forget held inputs (focus lost, gamepad removed)."""
|
|
166
|
+
self._down.clear()
|
|
167
|
+
self._levels.clear()
|
|
168
|
+
self._deltas.clear()
|
|
169
|
+
|
|
170
|
+
def is_down(self, inp: str) -> bool:
|
|
171
|
+
return inp in self._down
|
|
172
|
+
|
|
173
|
+
# per frame ----------------------------------------------------------------
|
|
174
|
+
def step(self, dt: float) -> Frame:
|
|
175
|
+
f = Frame(dt=float(dt), pressed=list(self._pending))
|
|
176
|
+
self._pending.clear()
|
|
177
|
+
for b in self.bindings:
|
|
178
|
+
if b.layer not in self.active or self.owner(b.input) != b.layer:
|
|
179
|
+
continue
|
|
180
|
+
if b.kind == "hold":
|
|
181
|
+
if b.input in self._down and b.mods <= self._mods:
|
|
182
|
+
f.held.add(b.action)
|
|
183
|
+
elif b.kind == "axis":
|
|
184
|
+
v = self._levels.get(b.input, 0.0)
|
|
185
|
+
if abs(v) > b.deadzone:
|
|
186
|
+
f.values[b.action] = f.values.get(b.action, 0.0) + v * b.scale
|
|
187
|
+
elif b.kind == "delta":
|
|
188
|
+
v = self._deltas.get(b.input, 0.0)
|
|
189
|
+
if v:
|
|
190
|
+
f.values[b.action] = f.values.get(b.action, 0.0) + v * b.scale
|
|
191
|
+
for k in {b.action for b in self.bindings if b.kind == "axis"} & f.values.keys():
|
|
192
|
+
f.values[k] = max(-1.0, min(1.0, f.values[k]))
|
|
193
|
+
self._deltas.clear()
|
|
194
|
+
return f
|
|
195
|
+
|
|
196
|
+
def table(self) -> List[dict]:
|
|
197
|
+
"""The bindings as rows for a help panel."""
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
"action": b.action,
|
|
201
|
+
"input": ("+".join(sorted(b.mods)) + "+" if b.mods else "") + b.input,
|
|
202
|
+
"kind": b.kind,
|
|
203
|
+
"layer": b.layer,
|
|
204
|
+
"label": b.label,
|
|
205
|
+
}
|
|
206
|
+
for b in self.bindings
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _b(*args, **kw) -> Binding:
|
|
211
|
+
return Binding(*args, **kw)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
DEFAULT_BINDINGS: List[Binding] = [
|
|
215
|
+
_b("attach", "key:K", label="attach the camera to the selected target, or free it"),
|
|
216
|
+
_b("attach", "pad:X"),
|
|
217
|
+
_b("view_next", "key:T", label="next attached view: chase, point of view, orbit"),
|
|
218
|
+
_b("view_next", "pad:DPAD_DOWN"),
|
|
219
|
+
_b("target_prev", "key:LEFT_BRACKET", label="previous target"),
|
|
220
|
+
_b("target_prev", "pad:LEFT_SHOULDER"),
|
|
221
|
+
_b("target_next", "key:RIGHT_BRACKET", label="next target"),
|
|
222
|
+
_b("target_next", "pad:RIGHT_SHOULDER"),
|
|
223
|
+
_b("home", "key:HOME", label="home view"),
|
|
224
|
+
_b("home", "pad:A"),
|
|
225
|
+
_b("orbit_yaw", "mouse:DRAG_X", "delta", layer="orbit", label="orbit around the target"),
|
|
226
|
+
_b("orbit_pitch", "mouse:DRAG_Y", "delta", layer="orbit"),
|
|
227
|
+
_b("orbit_yaw_rate", "pad:RIGHT_STICK_X", "axis", layer="orbit"),
|
|
228
|
+
_b("orbit_pitch_rate", "pad:RIGHT_STICK_Y", "axis", layer="orbit"),
|
|
229
|
+
_b("orbit_range", "mouse:WHEEL", "delta", layer="orbit", label="orbit distance"),
|
|
230
|
+
_b("orbit_range_rate", "pad:RIGHT_TRIGGER", "axis", layer="orbit", scale=-1.0),
|
|
231
|
+
_b("orbit_range_rate", "pad:LEFT_TRIGGER", "axis", layer="orbit"),
|
|
232
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Stage frame: up axis, horizontal basis, units and quaternion helpers.
|
|
2
|
+
|
|
3
|
+
Pure Python (standard library only), so it is unit-tested without Kit.
|
|
4
|
+
|
|
5
|
+
Conventions
|
|
6
|
+
-----------
|
|
7
|
+
Y up: up = +Y, h1 = +Z, h2 = +X; a target's forward axis defaults to +Z.
|
|
8
|
+
Z up: up = +Z, h1 = +X, h2 = +Y; a target's forward axis defaults to +X.
|
|
9
|
+
Yaw is measured from h1 toward h2 (h2 = up x h1), so a positive yaw turns
|
|
10
|
+
counterclockwise seen from above in both frames. Quaternions are (w, x, y, z).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
Vec3 = Tuple[float, float, float]
|
|
19
|
+
Quat = Tuple[float, float, float, float] # (w, x, y, z)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add(a: Sequence[float], b: Sequence[float]) -> Vec3:
|
|
23
|
+
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sub(a: Sequence[float], b: Sequence[float]) -> Vec3:
|
|
27
|
+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scale(a: Sequence[float], s: float) -> Vec3:
|
|
31
|
+
return (a[0] * s, a[1] * s, a[2] * s)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dot(a: Sequence[float], b: Sequence[float]) -> float:
|
|
35
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cross(a: Sequence[float], b: Sequence[float]) -> Vec3:
|
|
39
|
+
return (
|
|
40
|
+
a[1] * b[2] - a[2] * b[1],
|
|
41
|
+
a[2] * b[0] - a[0] * b[2],
|
|
42
|
+
a[0] * b[1] - a[1] * b[0],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def length(a: Sequence[float]) -> float:
|
|
47
|
+
return math.sqrt(dot(a, a))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize(a: Sequence[float]) -> Vec3:
|
|
51
|
+
n = length(a)
|
|
52
|
+
if n < 1e-12:
|
|
53
|
+
return (0.0, 0.0, 0.0)
|
|
54
|
+
return (a[0] / n, a[1] / n, a[2] / n)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def quat_rotate(q: Sequence[float], v: Sequence[float]) -> Vec3:
|
|
58
|
+
"""Rotate v by the unit quaternion q = (w, x, y, z)."""
|
|
59
|
+
w, x, y, z = q
|
|
60
|
+
n = math.sqrt(w * w + x * x + y * y + z * z)
|
|
61
|
+
if n < 1e-12:
|
|
62
|
+
return (float(v[0]), float(v[1]), float(v[2]))
|
|
63
|
+
w, x, y, z = w / n, x / n, y / n, z / n
|
|
64
|
+
u = (x, y, z)
|
|
65
|
+
t = scale(cross(u, v), 2.0)
|
|
66
|
+
return add(add(v, scale(t, w)), cross(u, t))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def quat_from_axis_angle(axis: Sequence[float], angle: float) -> Quat:
|
|
70
|
+
ax = normalize(axis)
|
|
71
|
+
s = math.sin(angle / 2.0)
|
|
72
|
+
return (math.cos(angle / 2.0), ax[0] * s, ax[1] * s, ax[2] * s)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_AXES = {
|
|
76
|
+
"Y": ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
|
77
|
+
"Z": ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0)),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UpFrame:
|
|
82
|
+
"""The stage's up axis and units, and a target's body forward axis.
|
|
83
|
+
|
|
84
|
+
meters_per_unit is UsdGeom.GetStageMetersPerUnit(stage); all rig tuning is in
|
|
85
|
+
meters and converted with to_units().
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
up: str = "Y",
|
|
91
|
+
meters_per_unit: float = 1.0,
|
|
92
|
+
body_forward: Sequence[float] | None = None,
|
|
93
|
+
):
|
|
94
|
+
up = str(up).upper()
|
|
95
|
+
if up not in _AXES:
|
|
96
|
+
raise ValueError(f"up axis must be 'Y' or 'Z', not {up!r}")
|
|
97
|
+
self.axis = up
|
|
98
|
+
self.up, self.h1 = _AXES[up]
|
|
99
|
+
self.h2 = cross(self.up, self.h1)
|
|
100
|
+
self.meters_per_unit = float(meters_per_unit) if meters_per_unit else 1.0
|
|
101
|
+
self.body_forward = normalize(body_forward) if body_forward else self.h1
|
|
102
|
+
|
|
103
|
+
def to_units(self, meters: float) -> float:
|
|
104
|
+
return meters / self.meters_per_unit
|
|
105
|
+
|
|
106
|
+
def horizontal(self, v: Sequence[float]) -> Vec3:
|
|
107
|
+
"""v with its up component removed."""
|
|
108
|
+
return sub(v, scale(self.up, dot(v, self.up)))
|
|
109
|
+
|
|
110
|
+
def yaw_of(self, v: Sequence[float]) -> float | None:
|
|
111
|
+
"""Yaw (radians, from h1 toward h2) of v's horizontal part; None if v is vertical."""
|
|
112
|
+
h = self.horizontal(v)
|
|
113
|
+
if length(h) < 1e-9:
|
|
114
|
+
return None
|
|
115
|
+
return math.atan2(dot(h, self.h2), dot(h, self.h1))
|
|
116
|
+
|
|
117
|
+
def heading_dir(self, yaw: float) -> Vec3:
|
|
118
|
+
"""Horizontal unit vector at the given yaw."""
|
|
119
|
+
return add(scale(self.h1, math.cos(yaw)), scale(self.h2, math.sin(yaw)))
|
|
120
|
+
|
|
121
|
+
def heading(self, q: Sequence[float]) -> float | None:
|
|
122
|
+
"""Yaw of a target's body forward axis rotated by q; None if it points straight up or down."""
|
|
123
|
+
return self.yaw_of(quat_rotate(q, self.body_forward))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Camera placement around a target: orbit, chase and point of view.
|
|
2
|
+
|
|
3
|
+
Positions are in stage units; distances passed in are already converted from meters with
|
|
4
|
+
UpFrame.to_units(). Every function returns plain tuples; the Kit layer turns them into
|
|
5
|
+
Gf types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from .frame import UpFrame, Vec3, add, cross, dot, length, normalize, quat_rotate, scale, sub
|
|
14
|
+
|
|
15
|
+
Matrix4 = Tuple[Tuple[float, float, float, float], ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def orbit_eye(
|
|
19
|
+
target: Sequence[float], yaw: float, elevation: float, distance: float, frame: UpFrame
|
|
20
|
+
) -> Vec3:
|
|
21
|
+
"""Eye at the given yaw and elevation (radians) and distance from target."""
|
|
22
|
+
c = math.cos(elevation)
|
|
23
|
+
d = add(scale(frame.heading_dir(yaw), c), scale(frame.up, math.sin(elevation)))
|
|
24
|
+
return add(target, scale(d, distance))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def orbit_from_eye(
|
|
28
|
+
target: Sequence[float], eye: Sequence[float], frame: UpFrame
|
|
29
|
+
) -> Tuple[float, float, float]:
|
|
30
|
+
"""(yaw, elevation, distance) that put the eye where it is now; the inverse of orbit_eye."""
|
|
31
|
+
d = sub(eye, target)
|
|
32
|
+
dist = length(d)
|
|
33
|
+
if dist < 1e-9:
|
|
34
|
+
return 0.0, 0.0, 0.0
|
|
35
|
+
yaw = frame.yaw_of(d)
|
|
36
|
+
elev = math.asin(max(-1.0, min(1.0, dot(d, frame.up) / dist)))
|
|
37
|
+
return (yaw if yaw is not None else 0.0), elev, dist
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def chase_view(
|
|
41
|
+
target: Sequence[float],
|
|
42
|
+
heading: float,
|
|
43
|
+
back: float,
|
|
44
|
+
up: float,
|
|
45
|
+
look_up: float,
|
|
46
|
+
look_ahead: float,
|
|
47
|
+
frame: UpFrame,
|
|
48
|
+
) -> Tuple[Vec3, Vec3, Vec3]:
|
|
49
|
+
"""(eye, look point, up vector) behind the target along its horizontal heading.
|
|
50
|
+
|
|
51
|
+
The camera's up is the stage up, so the horizon stays level whatever the target's
|
|
52
|
+
pitch and roll.
|
|
53
|
+
"""
|
|
54
|
+
fwd = frame.heading_dir(heading)
|
|
55
|
+
eye = add(add(target, scale(fwd, -back)), scale(frame.up, up))
|
|
56
|
+
look = add(add(target, scale(fwd, look_ahead)), scale(frame.up, look_up))
|
|
57
|
+
return eye, look, frame.up
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def chase_perspective(back: float, up: float, look_up: float) -> Tuple[float, float, float]:
|
|
61
|
+
"""The chase_view placement as a heading-relative orbit around the pivot
|
|
62
|
+
(target + look_up along up): (yaw relative to heading, elevation, distance).
|
|
63
|
+
|
|
64
|
+
Chase and orbit then share one parameterization, so a perspective chosen in orbit
|
|
65
|
+
can be kept for chase: chase_orbit_view with these values gives chase_view's camera
|
|
66
|
+
(with look_ahead 0).
|
|
67
|
+
"""
|
|
68
|
+
rise = up - look_up
|
|
69
|
+
return math.pi, math.atan2(rise, back), math.hypot(back, rise)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def chase_orbit_view(
|
|
73
|
+
target: Sequence[float],
|
|
74
|
+
heading: float,
|
|
75
|
+
yaw_rel: float,
|
|
76
|
+
elevation: float,
|
|
77
|
+
distance: float,
|
|
78
|
+
look_up: float,
|
|
79
|
+
frame: UpFrame,
|
|
80
|
+
) -> Tuple[Vec3, Vec3, Vec3]:
|
|
81
|
+
"""(eye, look point, up vector) on an orbit around target + look_up that turns with
|
|
82
|
+
the target's heading; the horizon stays level."""
|
|
83
|
+
pivot = add(target, scale(frame.up, look_up))
|
|
84
|
+
eye = orbit_eye(pivot, heading + yaw_rel, elevation, distance, frame)
|
|
85
|
+
return eye, pivot, frame.up
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pov_view(
|
|
89
|
+
target: Sequence[float],
|
|
90
|
+
q: Sequence[float],
|
|
91
|
+
forward: float,
|
|
92
|
+
up: float,
|
|
93
|
+
right: float,
|
|
94
|
+
frame: UpFrame,
|
|
95
|
+
) -> Tuple[Vec3, Vec3, Vec3]:
|
|
96
|
+
"""(eye, look point, up vector) fixed in the target's body frame, full attitude.
|
|
97
|
+
|
|
98
|
+
The body frame at rest is (body_forward, stage up, body_forward x up); the offsets are
|
|
99
|
+
along those axes.
|
|
100
|
+
"""
|
|
101
|
+
f0 = frame.body_forward
|
|
102
|
+
u0 = frame.up
|
|
103
|
+
r0 = normalize(cross(f0, u0))
|
|
104
|
+
f = quat_rotate(q, f0)
|
|
105
|
+
u = quat_rotate(q, u0)
|
|
106
|
+
r = quat_rotate(q, r0)
|
|
107
|
+
eye = add(add(add(target, scale(f, forward)), scale(u, up)), scale(r, right))
|
|
108
|
+
return eye, add(eye, f), u
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def look_at_matrix(eye: Sequence[float], look: Sequence[float], up: Sequence[float]) -> Matrix4:
|
|
112
|
+
"""Camera-to-world matrix, row-major with the translation in the last row.
|
|
113
|
+
|
|
114
|
+
The same layout as Gf.Matrix4d().SetLookAt(eye, look, up).GetInverse(): the camera
|
|
115
|
+
looks along its -Z with +Y up.
|
|
116
|
+
"""
|
|
117
|
+
f = normalize(sub(look, eye))
|
|
118
|
+
r = cross(f, up)
|
|
119
|
+
if length(r) < 1e-9: # looking along up: pick any perpendicular
|
|
120
|
+
alt = (1.0, 0.0, 0.0) if abs(f[0]) < 0.9 else (0.0, 0.0, 1.0)
|
|
121
|
+
r = cross(f, alt)
|
|
122
|
+
r = normalize(r)
|
|
123
|
+
u = cross(r, f)
|
|
124
|
+
return (
|
|
125
|
+
(r[0], r[1], r[2], 0.0),
|
|
126
|
+
(u[0], u[1], u[2], 0.0),
|
|
127
|
+
(-f[0], -f[1], -f[2], 0.0),
|
|
128
|
+
(float(eye[0]), float(eye[1]), float(eye[2]), 1.0),
|
|
129
|
+
)
|