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 ADDED
@@ -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
+ )
@@ -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