1import { Quaternion, Vector3 } from "three";
2
3/** Shortest rotation vector. q and -q represent the same orientation. */
4export function rotationVector(rotation: Quaternion) {
5 const q = rotation.clone().normalize();
6 if (q.w < 0) q.set(-q.x, -q.y, -q.z, -q.w);
7 const length = Math.hypot(q.x, q.y, q.z);
8 return length < 1e-8
9 ? new Vector3()
10 : new Vector3(q.x, q.y, q.z).multiplyScalar((2 * Math.atan2(length, q.w)) / length);
11}
12function fromVector(vector: Vector3) {
13 const angle = vector.length();
14 return angle < 1e-8
15 ? new Quaternion()
16 : new Quaternion().setFromAxisAngle(vector.clone().divideScalar(angle), angle);
17}
18
19// Recovered from Bitrig 0.25's rotation dynamics: point gain, drag force limit,
20// velocity limits, release prediction/blend, and the 0.5 / 0.78 release spring.
21const gain = 0.006;
22const response = 0.5;
23const damping = 0.78;
24const frequency = (2 * Math.PI) / response;
25const decay = frequency * damping;
26const stepSeconds = 1 / 120;
27// Fast releases combine exponential friction with a critically damped correction.
28const flickThreshold = 4.5;
29const flickLimit = 22;
30const spinDecay = 1.1;
31const settleFrequency = 2;
32
33/** A moving spring target follows cumulative camera-space gestures; release latches the nearest useful view. */
34export function createDeviceMotion(options: { choose: (rotation: Quaternion) => Quaternion }) {
35 const rotation = new Quaternion();
36 const target = new Quaternion();
37 const velocity = new Vector3();
38 const gestureVelocity = new Vector3();
39 let spring: { at: number; rotation: Quaternion; velocity: Vector3; steps: number } | null = null;
40 let spin: { at: number; rotation: Quaternion; velocity: Vector3; correction: Vector3 } | null =
41 null;
42 let drag: {
43 start: Quaternion;
44 rest: Quaternion;
45 x: number;
46 y: number;
47 error: Vector3;
48 at: number;
49 } | null = null;
50 let held = false;
51 let interruptedDrag = false;
52 let lastInput = -Infinity;
53 const beginSpring = (next: Quaternion, now: number) => {
54 target.copy(next).normalize();
55 spring = {
56 at: now,
57 rotation: rotation.clone(),
58 velocity: velocity.clone(),
59 steps: 0,
60 };
61 };
62 const release = (now: number) => {
63 if (!drag) return;
64 // A pause before lifting a pointer should not resurrect an old flick.
65 if (now - lastInput > 100) gestureVelocity.set(0, 0, 0);
66 const prediction = target
67 .clone()
68 .premultiply(fromVector(gestureVelocity.clone().multiplyScalar(0.085)));
69 velocity.multiplyScalar(0.2).addScaledVector(gestureVelocity, 0.8).clampLength(0, flickLimit);
70 const moved = drag.x !== 0 || drag.y !== 0;
71 const rest = drag.rest;
72 drag = null;
73 if (moved && velocity.length() >= flickThreshold) {
74 // Choose where the free spin would finish, then correct toward that view
75 // throughout the coast rather than handing off to a spring at low speed.
76 const projected = rotation
77 .clone()
78 .premultiply(fromVector(velocity.clone().multiplyScalar(1 / spinDecay)));
79 target.copy(options.choose(projected)).normalize();
80 spin = {
81 at: now,
82 rotation: rotation.clone(),
83 velocity: velocity.clone(),
84 correction: rotationVector(target.clone().multiply(projected.invert())),
85 };
86 return;
87 }
88 if (moved) beginSpring(options.choose(prediction), now);
89 else beginSpring(rest, now);
90 };
91 const advance = (now: number, reduced = false) => {
92 if (held || !Number.isFinite(now)) return false;
93 if (spin) {
94 const seconds = Math.max(0, (now - spin.at) / 1000);
95 const coast = Math.exp(-spinDecay * seconds);
96 const settle = Math.exp(-settleFrequency * seconds);
97 const correction = fromVector(
98 spin.correction.clone().multiplyScalar(1 - (1 + settleFrequency * seconds) * settle),
99 );
100 rotation
101 .copy(spin.rotation)
102 .premultiply(fromVector(spin.velocity.clone().multiplyScalar((1 - coast) / spinDecay)))
103 .premultiply(correction)
104 .normalize();
105 velocity
106 .copy(spin.velocity)
107 .multiplyScalar(coast)
108 .applyQuaternion(correction)
109 .addScaledVector(spin.correction, settleFrequency ** 2 * seconds * settle);
110 if (reduced || (rotation.angleTo(target) < 0.01 && velocity.length() < 0.08)) {
111 spin = null;
112 rotation.copy(target);
113 velocity.set(0, 0, 0);
114 }
115 return true;
116 }
117 if (drag) {
118 // Follow the target during the gesture. Retain the logarithm's winding so
119 // a long drag cannot reverse its spring force when crossing a half turn.
120 const seconds = Math.min(0.05, Math.max(0, (now - drag.at) / 1000));
121 drag.at = now;
122 const steps = Math.ceil(seconds * 120);
123 const dt = steps ? seconds / steps : 0;
124 for (let step = 0; step < steps; step++) {
125 const error = rotationVector(target.clone().multiply(rotation.clone().invert()));
126 if (error.lengthSq() > 1e-10) {
127 const axis = error.clone().normalize();
128 const turns = Math.round((drag.error.dot(axis) - error.length()) / (2 * Math.PI));
129 error.addScaledVector(axis, turns * 2 * Math.PI);
130 }
131 drag.error.copy(error);
132 const omega = (2 * Math.PI) / 0.68;
133 const acceleration = error
134 .clampLength(0, 1.2)
135 .multiplyScalar(omega * omega)
136 .addScaledVector(velocity, -2 * 1.12 * omega);
137 velocity.addScaledVector(acceleration, dt).clampLength(0, 9);
138 rotation.premultiply(fromVector(velocity.clone().multiplyScalar(dt))).normalize();
139 }
140 if (reduced) {
141 rotation.copy(target);
142 velocity.set(0, 0, 0);
143 }
144 return steps > 0 || reduced;
145 }
146 if (!spring) return false;
147 const seconds = Math.max(0, (now - spring.at) / 1000);
148 const steps = Math.min(240, Math.floor(seconds / stepSeconds));
149 const integrate = (q: Quaternion, speed: Vector3, dt: number) => {
150 const error = rotationVector(target.clone().multiply(q.clone().invert()));
151 const acceleration = error
152 .multiplyScalar(frequency * frequency)
153 .addScaledVector(speed, -2 * decay);
154 speed.addScaledVector(acceleration, dt).clampLength(0, 9);
155 q.premultiply(fromVector(speed.clone().multiplyScalar(dt))).normalize();
156 };
157 // Fixed ticks make release independent of display refresh rate. The final
158 // partial tick interpolates the next tick without committing it. This also
159 // keeps displayed angular speed bounded between tick boundaries.
160 for (; spring.steps < steps; spring.steps++)
161 integrate(spring.rotation, spring.velocity, stepSeconds);
162 rotation.copy(spring.rotation);
163 velocity.copy(spring.velocity);
164 const nextRotation = spring.rotation.clone();
165 const nextVelocity = spring.velocity.clone();
166 integrate(nextRotation, nextVelocity, stepSeconds);
167 const fraction = Math.min(1, (seconds - steps * stepSeconds) / stepSeconds);
168 rotation.slerp(nextRotation, fraction);
169 velocity.lerp(nextVelocity, fraction);
170 if (
171 reduced ||
172 steps === 240 ||
173 (rotation.angleTo(target) < 0.0005 && velocity.length() < 0.005)
174 ) {
175 rotation.copy(target);
176 velocity.set(0, 0, 0);
177 spring = null;
178 }
179 return true;
180 };
181 const beginDrag = (now: number) => {
182 if (held || drag) return;
183 advance(now);
184 drag = {
185 start: rotation.clone(),
186 rest: target.clone(),
187 x: 0,
188 y: 0,
189 error: new Vector3(),
190 at: now,
191 };
192 target.copy(rotation);
193 gestureVelocity.set(0, 0, 0);
194 lastInput = -Infinity;
195 spring = null;
196 spin = null;
197 };
198 return {
199 rotation,
200 setPose(next: Quaternion, now: number, immediate = false) {
201 advance(now);
202 drag = null;
203 spin = null;
204 beginSpring(next, now);
205 if (immediate) {
206 rotation.copy(target);
207 velocity.set(0, 0, 0);
208 spring = null;
209 }
210 },
211 dragActive(active: boolean, now: number) {
212 if (active) {
213 // A pointer takes over at the displayed pose, even if a wheel gesture
214 // was paused with its target still ahead of the spring.
215 if (drag || spin) {
216 advance(now);
217 drag = null;
218 spin = null;
219 }
220 beginDrag(now);
221 } else {
222 advance(now);
223 release(now);
224 }
225 },
226 /** Deltas are CSS pixels, independent of panel size and event partitioning. */
227 orbit(x: number, y: number, now: number) {
228 if (held || ![x, y, now].every(Number.isFinite) || (!x && !y)) return;
229 advance(now);
230 beginDrag(now);
231 if (!drag) return;
232 const previous = target.clone();
233 drag.x += x;
234 drag.y += y;
235 target
236 .copy(fromVector(new Vector3(drag.y * gain, drag.x * gain, 0)))
237 .multiply(drag.start)
238 .normalize();
239 const seconds = (now - lastInput) / 1000;
240 gestureVelocity.copy(rotationVector(target.clone().multiply(previous.invert())));
241 if (seconds > 0 && seconds < 0.1)
242 gestureVelocity.divideScalar(seconds).clampLength(0, flickLimit);
243 else gestureVelocity.set(0, 0, 0);
244 lastInput = now;
245 },
246 /** Captured device touches and hinge edits freeze projection, independently of orbit dragging. */
247 hold(active: boolean, now: number) {
248 if (held === active) return;
249 if (active) {
250 interruptedDrag = drag !== null || spin !== null;
251 drag = null;
252 spin = null;
253 spring = null;
254 velocity.set(0, 0, 0);
255 }
256 held = active;
257 if (!active) {
258 if (interruptedDrag) beginSpring(options.choose(rotation.clone()), now);
259 else if (rotation.angleTo(target) > 0.0005) beginSpring(target, now);
260 interruptedDrag = false;
261 }
262 },
263 advance,
264 needsFrame() {
265 return (
266 !held &&
267 (spring !== null ||
268 spin !== null ||
269 (drag !== null && (rotation.angleTo(target) > 0.0005 || velocity.length() > 0.005)))
270 );
271 },
272 reset(next: Quaternion, now: number) {
273 advance(now);
274 drag = null;
275 spin = null;
276 beginSpring(next, now);
277 },
278 };
279}