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.
- vantagekit/__init__.py +7 -0
- vantagekit/core/__init__.py +32 -0
- vantagekit/core/bindings.py +232 -0
- vantagekit/core/frame.py +123 -0
- vantagekit/core/rig_math.py +129 -0
- vantagekit/core/smoothing.py +126 -0
- vantagekit/core/targets.py +93 -0
- vantagekit/kit/__init__.py +20 -0
- vantagekit/kit/controls.py +202 -0
- vantagekit/kit/events.py +53 -0
- vantagekit/kit/input.py +152 -0
- vantagekit/kit/native_nav.py +84 -0
- vantagekit/kit/overlay.py +95 -0
- vantagekit/kit/rig.py +346 -0
- vantagekit-0.6.1.dist-info/METADATA +84 -0
- vantagekit-0.6.1.dist-info/RECORD +18 -0
- vantagekit-0.6.1.dist-info/WHEEL +4 -0
- vantagekit-0.6.1.dist-info/licenses/LICENSE +21 -0
vantagekit/__init__.py
ADDED
|
@@ -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
|
+
]
|
vantagekit/core/frame.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Frame-rate independent smoothing.
|
|
2
|
+
|
|
3
|
+
A fixed blend per frame (x += 0.1 * (target - x)) converges twice as fast at 120 fps as at
|
|
4
|
+
60 fps. Here every filter takes the frame's dt and a time constant tau in seconds, with
|
|
5
|
+
|
|
6
|
+
alpha = 1 - exp(-dt / tau)
|
|
7
|
+
|
|
8
|
+
Smoothed3 and SmoothedAngle solve dy/dt = (x - y) / tau exactly with the target moving in
|
|
9
|
+
a straight line between frames (a first-order hold):
|
|
10
|
+
|
|
11
|
+
y1 = x1 - v tau + (y0 - x0 + v tau) alpha', v = (x1 - x0) / dt, alpha' = exp(-dt / tau)
|
|
12
|
+
|
|
13
|
+
A plain blend toward the newest target (y += alpha (x1 - y)) treats the target as having
|
|
14
|
+
jumped at the start of the frame. For a target moving at speed v its lag is then
|
|
15
|
+
v dt exp(-dt/tau) / (1 - exp(-dt/tau)), which depends on dt when dt is near tau: with
|
|
16
|
+
tau 0.05 s it lagged 0.034 s at 37 ms frames and 0.028 s at 52 ms, so a chase camera
|
|
17
|
+
behind a boat at 15 m/s jumped by about 8 cm whenever the frame time changed (slop-fest,
|
|
18
|
+
2026-11-09). With the hold the lag is v tau at any frame time, and N frames of dt/N reach
|
|
19
|
+
the same value as one frame of dt for any target that moves in a straight line.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from typing import Sequence, Tuple
|
|
26
|
+
|
|
27
|
+
from .frame import Vec3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def exp_alpha(dt: float, tau: float) -> float:
|
|
31
|
+
"""Blend fraction for one step of dt seconds toward the target; 1 when tau <= 0."""
|
|
32
|
+
if tau <= 0.0:
|
|
33
|
+
return 1.0
|
|
34
|
+
if dt <= 0.0:
|
|
35
|
+
return 0.0
|
|
36
|
+
return 1.0 - math.exp(-dt / tau)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def wrap_angle(a: float) -> float:
|
|
40
|
+
"""a wrapped to (-pi, pi]."""
|
|
41
|
+
a = math.fmod(a + math.pi, 2.0 * math.pi)
|
|
42
|
+
if a <= 0.0:
|
|
43
|
+
a += 2.0 * math.pi
|
|
44
|
+
return a - math.pi
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def hold_step(y0: float, x0: float, x1: float, dt: float, tau: float) -> float:
|
|
48
|
+
"""One exact step of dy/dt = (x - y) / tau with x moving linearly from x0 to x1 over dt."""
|
|
49
|
+
if tau <= 0.0:
|
|
50
|
+
return x1
|
|
51
|
+
if dt <= 0.0:
|
|
52
|
+
return y0
|
|
53
|
+
v_tau = (x1 - x0) / dt * tau
|
|
54
|
+
return x1 - v_tau + (y0 - x0 + v_tau) * math.exp(-dt / tau)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Smoothed3:
|
|
58
|
+
"""A 3-vector following its target with time constant tau (seconds); its lag behind a
|
|
59
|
+
target moving at speed v is v tau whatever the frame times (first-order hold)."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, tau: float):
|
|
62
|
+
self.tau = float(tau)
|
|
63
|
+
self.value: Vec3 | None = None
|
|
64
|
+
self._target: Vec3 | None = None
|
|
65
|
+
|
|
66
|
+
def snap(self, v: Sequence[float]) -> Vec3:
|
|
67
|
+
self.value = (float(v[0]), float(v[1]), float(v[2]))
|
|
68
|
+
self._target = self.value
|
|
69
|
+
return self.value
|
|
70
|
+
|
|
71
|
+
def update(self, target: Sequence[float], dt: float) -> Vec3:
|
|
72
|
+
if self.value is None or self._target is None:
|
|
73
|
+
return self.snap(target)
|
|
74
|
+
x1 = (float(target[0]), float(target[1]), float(target[2]))
|
|
75
|
+
if dt <= 0.0:
|
|
76
|
+
return self.value
|
|
77
|
+
y, x0 = self.value, self._target
|
|
78
|
+
self.value = tuple(hold_step(y[i], x0[i], x1[i], dt, self.tau) for i in range(3))
|
|
79
|
+
self._target = x1
|
|
80
|
+
return self.value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SmoothedAngle:
|
|
84
|
+
"""An angle (radians) following its target along the shorter way around, with the
|
|
85
|
+
same first-order hold as Smoothed3 (a steady turn lags by its rate times tau)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, tau: float):
|
|
88
|
+
self.tau = float(tau)
|
|
89
|
+
self.value: float | None = None
|
|
90
|
+
self._target: float | None = None
|
|
91
|
+
|
|
92
|
+
def snap(self, a: float) -> float:
|
|
93
|
+
self.value = wrap_angle(float(a))
|
|
94
|
+
self._target = self.value
|
|
95
|
+
return self.value
|
|
96
|
+
|
|
97
|
+
def update(self, target: float, dt: float) -> float:
|
|
98
|
+
if self.value is None or self._target is None:
|
|
99
|
+
return self.snap(target)
|
|
100
|
+
if dt <= 0.0:
|
|
101
|
+
return self.value
|
|
102
|
+
# Work relative to the previous target so wrapping never adds a turn
|
|
103
|
+
x0 = self._target
|
|
104
|
+
x1 = x0 + wrap_angle(float(target) - x0)
|
|
105
|
+
y0 = x0 + wrap_angle(self.value - x0)
|
|
106
|
+
self.value = wrap_angle(hold_step(y0, x0, x1, dt, self.tau))
|
|
107
|
+
self._target = wrap_angle(x1)
|
|
108
|
+
return self.value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def spring_step(
|
|
112
|
+
x: float, v: float, target: float, dt: float, omega: float
|
|
113
|
+
) -> Tuple[float, float]:
|
|
114
|
+
"""One exact step of a critically damped spring; returns (x, v).
|
|
115
|
+
|
|
116
|
+
omega (1/s) sets the stiffness: the spring settles in about 4/omega seconds with no
|
|
117
|
+
overshoot from rest. Exact for any dt, so it does not depend on frame rate.
|
|
118
|
+
"""
|
|
119
|
+
if dt <= 0.0:
|
|
120
|
+
return x, v
|
|
121
|
+
c1 = x - target
|
|
122
|
+
c2 = v + omega * c1
|
|
123
|
+
e = math.exp(-omega * dt)
|
|
124
|
+
x_new = target + (c1 + c2 * dt) * e
|
|
125
|
+
v_new = (c2 - omega * (c1 + c2 * dt)) * e
|
|
126
|
+
return x_new, v_new
|