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.
Files changed (24) hide show
  1. vantagekit-0.6.1/.gitignore +5 -0
  2. vantagekit-0.6.1/LICENSE +21 -0
  3. vantagekit-0.6.1/PKG-INFO +84 -0
  4. vantagekit-0.6.1/kit/README.md +43 -0
  5. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/__init__.py +7 -0
  6. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/__init__.py +32 -0
  7. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/bindings.py +232 -0
  8. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/frame.py +123 -0
  9. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/rig_math.py +129 -0
  10. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/smoothing.py +126 -0
  11. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/core/targets.py +93 -0
  12. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/__init__.py +20 -0
  13. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/controls.py +202 -0
  14. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/events.py +53 -0
  15. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/input.py +152 -0
  16. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/native_nav.py +84 -0
  17. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/overlay.py +95 -0
  18. vantagekit-0.6.1/kit/exts/lhzn.vantagekit/vantagekit/kit/rig.py +346 -0
  19. vantagekit-0.6.1/kit/pyproject.toml +5 -0
  20. vantagekit-0.6.1/kit/tests/test_bindings.py +150 -0
  21. vantagekit-0.6.1/kit/tests/test_rig_math.py +185 -0
  22. vantagekit-0.6.1/kit/tests/test_smoothing.py +128 -0
  23. vantagekit-0.6.1/kit/tests/test_targets.py +45 -0
  24. vantagekit-0.6.1/pyproject.toml +44 -0
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .DS_Store
3
+ __pycache__/
4
+ .pytest_cache/
5
+ dist/
@@ -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,7 @@
1
+ """vantagekit for Omniverse Kit viewers.
2
+
3
+ vantagekit.core is pure Python (tested without Kit); vantagekit.kit wraps it for Kit.
4
+ This file imports neither, so importing the core never pulls in Kit.
5
+ """
6
+
7
+ __version__ = "0.6.1"
@@ -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
+ )