From 9ae890b4b194ec12e0d11887cbc185c15233d7db Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 18 Oct 2026 20:36:52 +0200 Subject: [PATCH 1/2] fix(cursor): land the flat cursor on each click, and smooth less by default Click effects (bounce, impact ring, ctx.clicks) sit on the raw click point, but the flat sprite was drawn on the smoothed spring, which trails the pointer by ~190 ms at the 0.67 default: on a click right after a move the effects landed up to 150 px beside the cursor. The flat sprite now uses pinned_at like the 3D cursor, so the drawn tip, ctx.cursor and ctx.clicks meet at every click whatever the smoothing. pinned_at also starts a click's approach only after the previous click's hold, so two clicks 200 ms apart each get the tip on their own point. New projects default to 0.3 smoothing (~95 ms of lag). --- crates/compositor/src/cursor.rs | 10 ++++++-- crates/compositor/src/frame_geometry.rs | 32 +++++++++++++++++++++---- src/lib/projectDefaults.ts | 3 ++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index d9c104a822..052ecf726a 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -368,7 +368,9 @@ 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. Continue et de pente 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 @@ -378,7 +380,11 @@ impl CursorTrack { 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] - PIN_HOLD_S).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) }; 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, From 7331e99f33db8a7d06164a88859e9eb89377ffcd Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 18 Oct 2026 22:16:00 +0200 Subject: [PATCH 2/2] fix(cursor): share a short gap between a click's hold and the next approach Two clicks on distinct points under 100 ms apart cut the second approach to 1 ms, so the tip jumped between them in one frame. Under twice the hold, the hold and the next approach now split the gap evenly. --- crates/compositor/src/cursor.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 052ecf726a..4eb981919c 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -369,24 +369,29 @@ impl CursorTrack { /// `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. 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. Continue et de pente continue en `t` ; - /// hors de ces fenêtres, `at(t)` à l'identique. + /// 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 { let approach = match i.checked_sub(1) { - Some(p) => (self.clicks[i] - self.clicks[p] - PIN_HOLD_S).clamp(1e-3, PIN_APPROACH_S), + 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 { @@ -954,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]