forked from getopenscreen/openscreen
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcursor.rs
More file actions
294 lines (274 loc) · 13.4 KB
/
Copy pathcursor.rs
File metadata and controls
294 lines (274 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! Piste curseur depuis un `.cursor.json` openscreen. Fournit position interpolée
//! et facteur de « click bounce » — consommés par frame (temps fractionnaire), donc
//! le motion blur du curseur vient gratuitement du supersampling temporel.
use anyhow::{Context, Result};
// Paramètres de suivi auto — parité stricte avec
// `src/lib/zoomMath/constants.ts` (AUTO_FOLLOW_PARAMS), partagés
// là-bas entre preview et export pour que la caméra suive le curseur à l'identique.
const AUTO_FOLLOW_MIN_FACTOR: f32 = 0.1;
const AUTO_FOLLOW_MAX_FACTOR: f32 = 0.25;
const AUTO_FOLLOW_RAMP_DISTANCE: f32 = 0.15;
const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0;
#[derive(Clone)]
pub struct CursorTrack {
/// (t_secondes, cx, cy) normalisés dans le cadre screen, triés.
samples: Vec<(f32, f32, f32)>,
/// Même piste après lissage exponentiel adaptatif — ce que le suivi auto du zoom doit
/// consommer. `samples` reste la piste BRUTE : le rendu du curseur lui-même et le click
/// bounce doivent coller à la position réelle, seule la caméra est amortie.
follow_samples: Vec<(f32, f32, f32)>,
/// instants de clic (secondes) dans la fenêtre.
clicks: Vec<f32>,
/// CHANGEMENTS d'état du curseur : (instant, `"arrow"` / `"text"` / `"pointer"` / …), triés.
/// Une fonction en escalier, pas une valeur par échantillon : l'état tient sur des secondes
/// entières alors que la position est échantillonnée à ~120 Hz, donc n'enregistrer que les
/// transitions garde cette liste minuscule et rend `type_at` trivial.
types: Vec<(f32, String)>,
}
/// Interpolation linéaire dans une liste `(t, x, y)` triée ; saturation aux bornes.
fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> {
if samples.is_empty() {
return None;
}
if t <= samples[0].0 {
let s = samples[0];
return Some((s.1, s.2));
}
if t >= samples[samples.len() - 1].0 {
let s = *samples.last().unwrap();
return Some((s.1, s.2));
}
// recherche du segment encadrant
let i = samples.partition_point(|s| s.0 <= t);
let a = samples[i - 1];
let b = samples[i];
let f = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f))
}
/// Port de `advanceFollowFocus` (`cursorFollowUtils.ts`) : lissage exponentiel dont le facteur
/// croît avec la distance à la cible (loin = rattrape vite, près = décélère), corrigé en temps
/// pour être indépendant de la cadence.
///
/// La version TS est **séquentielle** : elle avance un `prev` d'une frame à l'autre. Rejouer ça
/// par frame ici ferait dépendre l'image du chemin parcouru pour l'atteindre — deux rendus du
/// même instant divergeraient selon qu'on y arrive en lecture ou par un seek, et la preview ne
/// correspondrait plus à l'export. On applique donc le même filtre UNE fois, sur les
/// échantillons de télémétrie eux-mêmes : le résultat est identique en lecture linéaire et reste
/// une pure fonction de `t`.
fn smooth_follow_samples(samples: &[(f32, f32, f32)]) -> Vec<(f32, f32, f32)> {
let mut smoothed: Vec<(f32, f32, f32)> = Vec::with_capacity(samples.len());
let mut prev: Option<(f32, f32, f32)> = None;
for &(t, x, y) in samples {
let Some((prev_t, px, py)) = prev else {
smoothed.push((t, x, y));
prev = Some((t, x, y));
continue;
};
let dt_ms = (t - prev_t) * 1000.0;
if !(dt_ms > 0.0) {
// Horodatages dupliqués : on garde la valeur déjà lissée plutôt que de diviser par 0.
smoothed.push((t, px, py));
prev = Some((t, px, py));
continue;
}
let (dx, dy) = (x - px, y - py);
let distance = (dx * dx + dy * dy).sqrt();
let ramp = (distance / AUTO_FOLLOW_RAMP_DISTANCE).min(1.0);
let base = AUTO_FOLLOW_MIN_FACTOR + (AUTO_FOLLOW_MAX_FACTOR - AUTO_FOLLOW_MIN_FACTOR) * ramp;
let factor = 1.0 - (1.0 - base).powf(dt_ms / AUTO_FOLLOW_REFERENCE_MS);
let (nx, ny) = (px + dx * factor, py + dy * factor);
smoothed.push((t, nx, ny));
prev = Some((t, nx, ny));
}
smoothed
}
impl CursorTrack {
/// Seul point de construction : garantit que `follow_samples` est toujours dérivé des
/// échantillons courants. Une piste re-lissée (`smoothed`) recalcule donc aussi son suivi,
/// pour que la caméra suive la trajectoire que l'utilisateur voit réellement.
fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec<f32>, types: Vec<(f32, String)>) -> CursorTrack {
let follow_samples = smooth_follow_samples(&samples);
CursorTrack { samples, follow_samples, clicks, types }
}
/// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la
/// première (enregistrement sans état tagué → l'appelant retombe sur la flèche).
pub fn type_at(&self, t: f32) -> Option<&str> {
let i = self.types.partition_point(|(tc, _)| *tc <= t);
(i > 0).then(|| self.types[i - 1].1.as_str())
}
/// Nombre d'échantillons de la piste (utile au diag de chargement).
pub fn sample_count(&self) -> usize {
self.samples.len()
}
/// Charge la fenêtre [offset_ms, offset_ms + dur_s*1000] et la ramène à t=0.
pub fn load(path: &str, offset_ms: f64, dur_s: f64) -> Result<CursorTrack> {
let txt = std::fs::read_to_string(path).with_context(|| format!("lecture {path}"))?;
let v: serde_json::Value = serde_json::from_str(&txt)?;
let arr = v["samples"].as_array().context("samples[]")?;
let mut samples = Vec::new();
let mut clicks = Vec::new();
let mut types: Vec<(f32, String)> = Vec::new();
let end = offset_ms + dur_s * 1000.0;
for s in arr {
let tm = s["timeMs"].as_f64().unwrap_or(-1.0);
if tm < offset_ms || tm > end {
continue;
}
let t = ((tm - offset_ms) / 1000.0) as f32;
let cx = s["cx"].as_f64().unwrap_or(0.0) as f32;
let cy = s["cy"].as_f64().unwrap_or(0.0) as f32;
samples.push((t, cx, cy));
if s["interactionType"].as_str() == Some("click") {
clicks.push(t);
}
// Seules les TRANSITIONS sont retenues — voir `types`. Les échantillons sans
// `cursorType` (macOS ne le tague pas toujours) n'interrompent pas l'état courant :
// c'est une absence d'information, pas un retour à la flèche.
if let Some(ct) = s["cursorType"].as_str() {
if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) {
types.push((t, ct.to_string()));
}
}
}
samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
clicks.sort_by(|a, b| a.partial_cmp(b).unwrap());
types.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
Ok(CursorTrack::new(samples, clicks, types))
}
/// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est
/// échantillonnée trop finement pour piloter une caméra directement : la suivre au sample
/// près donne un pan nerveux. Voir `smooth_follow_samples`.
pub fn follow_at(&self, t: f32) -> Option<(f32, f32)> {
sample_at(&self.follow_samples, t)
}
/// Position (cx, cy) BRUTE au temps `t` (interpolation linéaire), ou None si hors piste.
pub fn at(&self, t: f32) -> Option<(f32, f32)> {
sample_at(&self.samples, t)
}
/// Facteur d'échelle « click bounce » — parité `getNativeCursorClickBounceScale` (TS,
/// `nativeCursor.ts`) : le curseur PRESSE (rétrécit, 0..38% de la fenêtre d'animation)
/// PUIS REBONDIT (grossit, 38..100%), pas un simple pop qui ne fait que grossir puis
/// redécroître. Seul le clic le plus récent précédant `t` compte (au-delà de la fenêtre,
/// un clic antérieur n'a plus aucun effet — contrairement à l'ancienne décroissance
/// exponentielle à queue infinie qui masquait ce bug).
pub fn bounce(&self, t: f32) -> f32 {
const ANIM_S: f32 = 0.26; // NATIVE_CURSOR_CLICK_ANIMATION_MS (TS) = 260ms
const PRESS_FRAC: f32 = 0.38;
let mut last_tc: Option<f32> = None;
for &tc in &self.clicks {
if tc <= t {
last_tc = Some(tc); // clics triés croissant -> garde le plus récent <= t
} else {
break;
}
}
let Some(tc) = last_tc else { return 1.0 };
let elapsed = (t - tc) / ANIM_S;
if elapsed >= 1.0 {
return 1.0;
}
if elapsed < PRESS_FRAC {
let press = (elapsed / PRESS_FRAC * std::f32::consts::PI).sin();
1.0 - press * 0.24
} else {
let rebound = ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin();
1.0 + rebound * 0.16
}
}
/// Piste repositionnée par un ressort-amortisseur (parité `cursorPathSmoothing.ts` :
/// resample à 240 Hz + intégration semi-implicite d'Euler). `factor` 0..1 = valeur brute
/// du slider (0 = passthrough, retourne un clone). Les clics restent sur leurs instants
/// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage).
pub fn smoothed(&self, factor: f32) -> CursorTrack {
if self.samples.len() < 2 || factor <= 0.0 {
return CursorTrack::new(self.samples.clone(), self.clicks.clone(), self.types.clone());
}
const STEP_S: f32 = 1.0 / 240.0;
let start = self.samples[0].0;
let end = self.samples[self.samples.len() - 1].0;
let step_count = (((end - start) / STEP_S).round() as usize).max(1);
let n = step_count + 1;
let mut times = Vec::with_capacity(n);
let mut raw_x = Vec::with_capacity(n);
let mut raw_y = Vec::with_capacity(n);
for i in 0..n {
let t = if i == n - 1 { end } else { start + i as f32 * STEP_S };
let (cx, cy) = self.at(t).unwrap_or((0.0, 0.0));
times.push(t);
raw_x.push(cx);
raw_y.push(cy);
}
let (stiffness, damping, mass) = cursor_spring_config(factor);
let xs = spring_smooth(&raw_x, stiffness, damping, mass, STEP_S);
let ys = spring_smooth(&raw_y, stiffness, damping, mass, STEP_S);
let samples = times.into_iter().zip(xs).zip(ys).map(|((t, x), y)| (t, x, y)).collect();
// Comme les clics, les changements d'état gardent leurs instants bruts : le lissage
// déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur.
CursorTrack::new(samples, self.clicks.clone(), self.types.clone())
}
}
/// Ressort-amortisseur, intégration semi-implicite (symplectique) d'Euler — stable pour ces
/// raideurs à la grille 240 Hz (port direct de `springSmooth` en TS).
fn spring_smooth(targets: &[f32], stiffness: f32, damping: f32, mass: f32, step_s: f32) -> Vec<f32> {
let mut out = vec![0.0f32; targets.len()];
if targets.is_empty() {
return out;
}
let mut x = targets[0];
let mut v = 0.0f32;
out[0] = x;
for i in 1..targets.len() {
let accel = (-stiffness * (x - targets[i]) - damping * v) / mass;
v += accel * step_s;
x += v * step_s;
out[i] = x;
}
out
}
/// Port direct de `getCursorSpringConfig` (TS) → (stiffness, damping, mass). N'accepte que
/// 0..1 (plage réelle du slider, cf. `RightPanes.tsx` : `smoothing * 100` sur un slider 0..100).
fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) {
let clamped = smoothing_factor.clamp(0.0, 2.0);
if clamped <= 0.0 {
return (1000.0, 100.0, 1.0);
}
const LEGACY_MAX: f32 = 0.5;
if clamped <= LEGACY_MAX {
let n = (clamped / LEGACY_MAX).clamp(0.0, 1.0);
return (760.0 - n * 420.0, 34.0 + n * 24.0, 0.55 + n * 0.45);
}
let n = ((clamped - LEGACY_MAX) / (2.0 - LEGACY_MAX)).clamp(0.0, 1.0);
(340.0 - n * 180.0, 58.0 + n * 22.0, 1.0 + n * 0.35)
}
#[cfg(test)]
mod tests {
use super::*;
/// L'état du curseur est une fonction en escalier : il tient jusqu'à la transition
/// suivante, il n'est pas interpolé, et avant la première il n'y en a pas.
#[test]
fn cursor_type_holds_until_the_next_transition() {
let track = CursorTrack::new(
vec![(0.0, 0.0, 0.0), (2.0, 1.0, 1.0)],
vec![],
vec![(0.5, "arrow".into()), (1.0, "text".into()), (1.5, "pointer".into())],
);
assert_eq!(track.type_at(0.0), None, "avant la première transition");
assert_eq!(track.type_at(0.5), Some("arrow"), "à l'instant même de la transition");
assert_eq!(track.type_at(0.9), Some("arrow"), "tient jusqu'à la suivante");
assert_eq!(track.type_at(1.2), Some("text"));
assert_eq!(track.type_at(99.0), Some("pointer"), "la dernière tient jusqu'à la fin");
}
/// Le lissage déplace la trajectoire, pas la chronologie : les états doivent survivre
/// intacts à `smoothed()`, comme les clics.
#[test]
fn smoothing_preserves_cursor_types() {
let track = CursorTrack::new(
vec![(0.0, 0.0, 0.0), (0.5, 0.4, 0.4), (1.0, 1.0, 1.0)],
vec![0.25],
vec![(0.0, "arrow".into()), (0.6, "text".into())],
);
let smoothed = track.smoothed(0.4);
assert_eq!(smoothed.type_at(0.1), Some("arrow"));
assert_eq!(smoothed.type_at(0.7), Some("text"));
}
}