diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index d9c104a822..4eb981919c 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -368,19 +368,30 @@ impl CursorTrack { /// Pour chaque clic `tc`, un poids vers son point : il monte en smoothstep sur /// `PIN_APPROACH_S`, vaut 1 sur `[tc, tc + PIN_HOLD_S]` (le contact), puis retombe en /// smoothstep sur `PIN_RELEASE_S`. Les clics s'appliquent dans l'ordre, le plus récent - /// l'emporte. Continue et de pente continue en `t` ; hors de ces fenêtres, `at(t)` à l'identique. + /// l'emporte. L'approche d'un clic ne commence qu'à la fin de la tenue du précédent : deux clics + /// rapprochés posent chacun la pointe sur leur point. Sous `2 * PIN_HOLD_S` d'écart, la tenue et + /// l'approche suivante se partagent l'écart à parts égales. Continue en `t` ; hors de ces + /// fenêtres, `at(t)` à l'identique. pub fn pinned_at(&self, t: f32) -> Option<(f32, f32)> { let (mut x, mut y) = self.at(t)?; let lo = self .clicks .partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); let hi = self.clicks.partition_point(|&tc| tc < t + PIN_APPROACH_S); + let hold = |i: usize| match self.clicks.get(i + 1) { + Some(&next) => ((next - self.clicks[i]) * 0.5).min(PIN_HOLD_S), + None => PIN_HOLD_S, + }; for i in lo..hi.max(lo) { let d = t - self.clicks[i]; let w = if d < 0.0 { - smoothstep01(1.0 + d / PIN_APPROACH_S) + let approach = match i.checked_sub(1) { + Some(p) => (self.clicks[i] - self.clicks[p] - hold(p)).clamp(1e-3, PIN_APPROACH_S), + None => PIN_APPROACH_S, + }; + smoothstep01(1.0 + d / approach) } else { - 1.0 - smoothstep01((d - PIN_HOLD_S) / PIN_RELEASE_S) + 1.0 - smoothstep01((d - hold(i)) / PIN_RELEASE_S) }; let (cx, cy) = self.click_points[i]; (x, y) = if w >= 1.0 { @@ -948,6 +959,24 @@ mod tests { ); } + /// Deux clics à 60 ms, sur deux points distincts : la pointe se pose sur chacun à son instant, et + /// passe de l'un à l'autre sans sauter d'une image à l'autre. + #[test] + fn close_clicks_on_distinct_points_glide_between_them() { + let samples = vec![(0.0, 0.2, 0.2), (1.0, 0.2, 0.2), (1.06, 0.6, 0.5), (2.0, 0.6, 0.5)]; + let track = CursorTrack::new(samples, vec![1.0, 1.06], vec![]).smoothed(0.67); + let (a, b): ((f32, f32), (f32, f32)) = ((0.2, 0.2), (0.6, 0.5)); + assert_eq!(track.pinned_at(1.0), Some(a)); + assert_eq!(track.pinned_at(1.06), Some(b)); + let gap = (b.0 - a.0).hypot(b.1 - a.1); + for k in 1000..1060 { + let p = track.pinned_at(k as f32 * 0.001).unwrap(); + let q = track.pinned_at((k + 1) as f32 * 0.001).unwrap(); + let step = (q.0 - p.0).hypot(q.1 - p.1); + assert!(step < 0.06 * gap, "{k} ms : saut de {step}"); + } + } + /// `pinned_at` pose le curseur sur le point BRUT du clic pendant le contact, même quand la /// piste lissée traîne loin derrière, reste continue, et rend `at()` loin des clics. #[test] diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index f7affa9dc4..f189f6f379 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3834,11 +3834,11 @@ impl<'a> CursorPlacer<'a> { } } - /// La pointe à `t`. Le curseur modélisé touche le plan à chaque clic : il se pose sur le point - /// cliqué brut (`pinned_at`), pas sur la piste lissée qui traîne derrière la souris. Le sprite - /// plat garde la piste telle quelle, son rendu est celui d'avant. + /// La pointe à `t`, posée sur le point cliqué brut à chaque clic (`pinned_at`), pas sur la piste + /// lissée qui traîne derrière la souris. Sprite plat comme modèle 3D : le rebond, l'anneau + /// d'impact et `ctx.clicks` se centrent sur ce point brut, la pointe doit s'y trouver. fn at(&self, t: f32) -> Option<(f32, f32)> { - if self.model3d { self.track.pinned_at(t) } else { self.track.at(t) } + self.track.pinned_at(t) } /// `None` hors de la coupe courante (zoom serré, hors écran). @@ -9279,6 +9279,30 @@ mod tests { assert_eq!(fx(2.5).cursor, Some(drawn.placement.upright_center()), "là où le curseur se dessine"); } + /// Le sprite plat aussi se pose sur le clic brut : à fort lissage, le ressort traîne loin derrière + /// une souris qui clique dès l'arrivée, mais la pointe dessinée, `ctx.cursor` et `ctx.clicks` + /// tombent sur le même pixel pendant le contact. + #[test] + fn the_flat_cursor_lands_on_the_click_under_smoothing() { + const TC: f32 = 1.5; + let scene = model_scene(); + let cfg = crate::config::all().pop().expect("cfg"); + let fg = full_frame_geometry(); + let raw = approach_track([0.08, 0.05], [0.7, 0.6], [0.2, 0.9], 2.0, 0.0, TC); + for smoothing in [0.0, 0.3, 0.67, 1.0] { + let track = raw.smoothed(smoothing); + for ms in [0, 40, 100] { + let input = cursor_input(&scene, &cfg, &track, TC + ms as f32 / 1000.0); + let fx = effect_pointer(&fg, &input); + let (cursor, click) = (fx.cursor.expect("pointe"), fx.clicks[0].0); + let drawn = plan_cursor(&fg, &input).expect("visible").placement.upright_center(); + let off = |a: [f32; 2], b: [f32; 2]| ((a[0] - b[0]) * 1920.0).hypot((a[1] - b[1]) * 1080.0); + assert!(off(cursor, click) < 0.5, "lissage {smoothing}, {ms} ms : {} px", off(cursor, click)); + assert!(off(drawn, click) < 0.5, "lissage {smoothing}, {ms} ms : {} px", off(drawn, click)); + } + } + } + /// Masqué par l'utilisateur, le curseur n'est plus dessiné, mais les effets gardent la pointe /// et les clics : un halo suit toujours la souris, une onde part toujours du clic. Quand c'est /// l'enregistrement qui cache la pointe, les effets la perdent, et gardent les clics d'avant. diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 124c480345..cfb838241f 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -264,7 +264,8 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { // 3.6 draws a 98 px arrow in a 1080p export, about five times the system one: the cursor // effects are part of the product, and a smaller arrow plays them down. 6 reaches 164 px. size: 3.6, - smoothing: 0.67, + // About 95 ms of spring lag: 0.67 trailed the pointer by 190 ms, 150 px on a quick click. + smoothing: 0.3, motionBlur: 0.35, // A light tap: 2.5 squashed the arrow to 40% and threw it to 140% in 260 ms on every click. clickBounce: 1,