Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 154 additions & 6 deletions crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub struct CursorTrack {
/// réellement cliqué. `smoothed()` la garde telle quelle, alors que la piste lissée passe
/// ailleurs à cet instant (cf. `pinned_at`).
click_points: Vec<(f32, f32)>,
/// CHANGEMENTS de visibilité : `(instant, visible)`, triés. Comme `types`, c'est une
/// fonction en escalier. Une piste ancienne sans champ `visible` reste visible.
visibility: Vec<(f32, bool)>,
/// 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 toutes les 33 ms (~30 Hz, cf.
Expand Down Expand Up @@ -135,6 +138,15 @@ impl CursorTrack {
samples: Vec<(f32, f32, f32)>,
clicks: Vec<f32>,
types: Vec<(f32, String)>,
) -> CursorTrack {
CursorTrack::new_with_visibility(samples, clicks, types, Vec::new())
}

pub(crate) fn new_with_visibility(
samples: Vec<(f32, f32, f32)>,
clicks: Vec<f32>,
types: Vec<(f32, String)>,
visibility: Vec<(f32, bool)>,
) -> CursorTrack {
let follow_samples = smooth_follow_samples(&samples);
let click_points = clicks
Expand All @@ -146,11 +158,23 @@ impl CursorTrack {
follow_samples,
clicks,
click_points,
visibility,
types,
view_cache: Default::default(),
}
}

/// Visibilité enregistrée au temps `t`. Les anciens sidecars qui n'ont pas ce champ sont
/// visibles par défaut, comme les lecteurs TypeScript.
pub fn visible_at(&self, t: f32) -> bool {
let i = self.visibility.partition_point(|(tc, _)| *tc <= t);
if i == 0 {
true
} else {
self.visibility[i - 1].1
}
}

/// É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> {
Expand All @@ -171,6 +195,21 @@ impl CursorTrack {
let mut samples = Vec::new();
let mut clicks = Vec::new();
let mut types: Vec<(f32, String)> = Vec::new();
let mut visibility_at_offset = true;
let mut visibility_sample_time = f64::NEG_INFINITY;
// A clipped track inherits the last recorded state at its source offset. Using the first
// later sample would reveal a cursor early when that sample is the transition to visible.
for s in arr {
let tm = s["timeMs"].as_f64().unwrap_or(-1.0);
if tm >= 0.0 && tm <= offset_ms && tm >= visibility_sample_time {
visibility_sample_time = tm;
visibility_at_offset = s
.get("visible")
.and_then(|value| value.as_bool())
.unwrap_or(true);
}
}
let mut visibility = vec![(0.0, visibility_at_offset)];
let end = offset_ms + dur_s * 1000.0;
for s in arr {
let tm = s["timeMs"].as_f64().unwrap_or(-1.0);
Expand All @@ -181,7 +220,14 @@ impl CursorTrack {
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") {
let visible = s
.get("visible")
.and_then(|value| value.as_bool())
.unwrap_or(true);
if visibility.last().map(|(_, previous)| *previous) != Some(visible) {
visibility.push((t, visible));
}
if visible && s["interactionType"].as_str() == Some("click") {
clicks.push(t);
}
// Seules les TRANSITIONS sont retenues — voir `types`. Le helper
Expand All @@ -204,7 +250,10 @@ impl CursorTrack {
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))
visibility.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
Ok(CursorTrack::new_with_visibility(
samples, clicks, types, visibility,
))
}

/// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est
Expand Down Expand Up @@ -411,10 +460,11 @@ impl CursorTrack {
/// 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(
return CursorTrack::new_with_visibility(
self.samples.clone(),
self.clicks.clone(),
self.types.clone(),
self.visibility.clone(),
);
}
const STEP_S: f32 = 1.0 / 240.0;
Expand All @@ -437,8 +487,33 @@ impl CursorTrack {
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);
// Une phase masquée coupe le ressort. Au retour, le curseur repart du premier point
// réellement visible au lieu de traverser la trajectoire cachée avec du retard.
let mut xs = Vec::with_capacity(n);
let mut ys = Vec::with_capacity(n);
let mut run_start = 0;
while run_start < n {
let visible = self.visible_at(times[run_start]);
let mut run_end = run_start + 1;
while run_end < n && self.visible_at(times[run_end]) == visible {
run_end += 1;
}
xs.extend(spring_smooth(
&raw_x[run_start..run_end],
stiffness,
damping,
mass,
STEP_S,
));
ys.extend(spring_smooth(
&raw_y[run_start..run_end],
stiffness,
damping,
mass,
STEP_S,
));
run_start = run_end;
}
let samples = times
.into_iter()
.zip(xs)
Expand All @@ -450,7 +525,12 @@ impl CursorTrack {
// cliqués restent ceux de la piste brute.
CursorTrack {
click_points: self.click_points.clone(),
..CursorTrack::new(samples, self.clicks.clone(), self.types.clone())
..CursorTrack::new_with_visibility(
samples,
self.clicks.clone(),
self.types.clone(),
self.visibility.clone(),
)
}
}

Expand Down Expand Up @@ -755,6 +835,74 @@ mod tests {
assert_eq!(smoothed.type_at(0.7), Some("text"));
}

#[test]
fn load_preserves_hidden_intervals_and_ignores_hidden_clicks() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"openscreen-cursor-visibility-{}-{}.json",
std::process::id(),
unique
));
std::fs::write(
&path,
r#"{"samples":[
{"timeMs":0,"cx":0.1,"cy":0.1,"visible":true},
{"timeMs":100,"cx":0.2,"cy":0.2,"visible":false,"interactionType":"click"},
{"timeMs":600,"cx":0.9,"cy":0.9,"visible":true,"interactionType":"click"}
]}"#,
)
.expect("write temp sidecar");
let path_str = path.to_str().expect("utf-8 temp path");
let track = CursorTrack::load(path_str, 0.0, 1.0).expect("load sidecar");
let clipped = CursorTrack::load(path_str, 200.0, 0.5).expect("load clipped sidecar");
let _ = std::fs::remove_file(&path);

assert!(track.visible_at(0.0));
assert!(!track.visible_at(0.1));
assert!(!track.visible_at(0.59));
assert!(track.visible_at(0.6));
assert_eq!(
track.clicks,
vec![0.6],
"le clic masqué ne doit pas animer le curseur"
);
assert!(
!clipped.visible_at(0.0),
"la fenêtre commence dans la phase masquée"
);
assert!(
!clipped.visible_at(0.39),
"l'état masqué tient jusqu'à la transition"
);
assert!(
clipped.visible_at(0.4),
"la transition visible garde son temps relatif"
);

let smoothed = track.smoothed(0.5);
assert!(
!smoothed.visible_at(0.3),
"le lissage garde la phase masquée"
);
assert!(smoothed.visible_at(0.6));
let reappeared = smoothed.at(0.6).expect("position at reappearance");
assert!(
(reappeared.0 - 0.9).abs() < 0.02 && (reappeared.1 - 0.9).abs() < 0.02,
"le ressort redémarre au point visible, sans traîne: {reappeared:?}"
);
}

#[test]
fn legacy_tracks_without_visibility_stay_visible() {
let track = CursorTrack::new(vec![(0.0, 0.1, 0.1), (1.0, 0.9, 0.9)], vec![], vec![]);
assert!(track.visible_at(0.0));
assert!(track.visible_at(0.5));
assert!(track.visible_at(10.0));
}

/// JSON null et une clé `cursorType` absente resetent vers la flèche,
/// au lieu de garder le dernier `pointer`/`text`.
#[test]
Expand Down
90 changes: 88 additions & 2 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3479,6 +3479,9 @@ pub fn cursor_plane_point(cut: [f32; 4], uv_max: [f32; 2], p: (f32, f32)) -> Opt
///
/// - curseur visible (`cursor_alpha`) : `cursor.show` explicite, la piste étant chargée même
/// curseur masqué (le focus auto la suit) ;
/// - curseur caché par l'application (`visible: false`) : ses clics ne sont pas chargés
/// (`CursorTrack::load`). Un clic visible va au bout même si le pointeur disparaît juste après
/// (un glisser sur un champ numérique) : le couper là ferait sauter l'écran en plein creux ;
/// - clics dans la fenêtre source du clip actif seulement (cf. `click_impact`) ;
/// - vitesse : poids `clamp(2 − vitesse, 0, 1)`. À 100× une frame couvre 3,3 s de source, la
/// courbe serait échantillonnée une fois, au hasard : une secousse d'une frame ;
Expand Down Expand Up @@ -3520,6 +3523,11 @@ fn click_impact_at(
pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorPlan> {
let (rw, rh) = (input.render_px[0], input.render_px[1]);

// Caché par l'application (`visible: false`) : rien de ce plan n'est dessiné, ni le sprite,
// ni le modèle et son ombre, ni l'anneau d'un clic. Les trois backends passent tous par ici.
if !input.track.visible_at(input.t) {
return None;
}
let alpha = cursor_alpha(input.scene, input.cfg, &input.live, input.track, input.t);
if alpha <= 0.001 {
return None;
Expand Down Expand Up @@ -3610,20 +3618,26 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP

let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0);
let has_scene = input.scene.is_some();
// La traînée ne remonte pas dans une phase cachée : à sa réapparition, le curseur ne vient pas
// de là où il était caché.
let trail_from = |t: f32| {
let prev = if input.track.visible_at(t) { place(at(t), g.s_dst_prev) } else { None };
prev.unwrap_or(placement)
};
let (taps, prev_placement) = if !has_scene {
let taps = input.cfg.mblur_n;
let prev = if taps <= 1 {
placement
} else {
place(at(input.t - 1.0 / FPS), g.s_dst_prev).unwrap_or(placement)
trail_from(input.t - 1.0 / FPS)
};
(taps, prev)
} else if blur01 <= 0.001 {
(1, placement)
} else {
// Intervalle d'obturateur court, borné à 1 frame (100% blur = 1 frame d'exposition)
let trail_dt = blur01 / FPS;
let prev = place(at(input.t - trail_dt), g.s_dst_prev).unwrap_or(placement);
let prev = trail_from(input.t - trail_dt);
let c_now = placement.upright_center();
let c_prev = prev.upright_center();
let dist_px = ((c_now[0] - c_prev[0]) * rw).hypot((c_now[1] - c_prev[1]) * rh);
Expand Down Expand Up @@ -6600,6 +6614,33 @@ mod tests {
}
}

/// Un clic visible dont le pointeur disparaît juste après (un glisser sur un champ numérique)
/// garde tout son impact : l'éteindre au masquage ferait sauter l'écran en plein creux.
#[test]
fn a_click_hidden_right_after_keeps_its_impact() {
let cfg = crate::config::all().pop().expect("au moins une config");
let samples: Vec<_> = (0..=90).map(|i| (i as f32 / 30.0, 0.6, 0.3)).collect();
let track = |visibility| -> &'static crate::cursor::CursorTrack {
Box::leak(Box::new(crate::cursor::CursorTrack::new_with_visibility(
samples.clone(),
vec![1.45],
vec![],
visibility,
)))
};
let json = zoomed_golden_scene_json()
.replace(r#""rotation":"none""#, r#""rotation":"iso""#)
.replace(r#""cursor":{"#, r#""cursor":{"clickImpact":true,"#);
let scene = Scene::from_json(&json).expect("scène");
let tilt = |track| {
plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) })
.zoom_rotation_dyn
};
let on = tilt(track(vec![]));
assert!(on[1] > 0.75 * crate::regions::CLICK_IMPACT_DEG, "garde : {on:?}");
assert_eq!(tilt(track(vec![(0.0, true), (1.48, false)])), on);
}

/// L'orbite en focus auto passe par `plan_frame` : l'écran n'est pas incliné, la caméra vise et tourne
/// avec le pointeur lu dans le RECADRAGE, la boîte zoome sur son centre sans glisser, la mise au
/// point suit la visée, un clic fait reculer l'œil sans presser l'écran, et curseur masqué
Expand Down Expand Up @@ -8346,6 +8387,51 @@ mod tests {
assert_eq!(plan(false).for_backend(true).taps, plan(false).taps, "le sprite plat garde sa traînée");
}

/// Caché par l'application (`visible: false`), le curseur ne dessine rien, sprite plat comme
/// modèle (ombre et anneau compris), et sa traînée ne remonte pas dans la phase cachée à la
/// réapparition : il y filait à droite, il réapparaît là où il avait cliqué.
#[test]
fn a_hidden_phase_draws_no_cursor_and_no_trail_into_it() {
let scene = model_scene();
let cfg = crate::config::all().pop().expect("cfg");
let fg = full_frame_geometry();
let track = crate::cursor::CursorTrack::new_with_visibility(
vec![(0.0, 0.3, 0.6), (1.0, 0.3, 0.6), (1.1, 0.35, 0.6), (1.467, 0.9, 0.6), (1.5, 0.3, 0.6), (3.0, 0.3, 0.6)],
vec![1.0],
vec![],
vec![(0.0, true), (1.1, false), (1.5, true)],
);
let plan = |model3d: bool, t: f32| {
plan_cursor(
&fg,
&CursorPlanInput {
render_px: [1920.0, 1080.0],
u_max: 1.0,
v_max: 1.0,
cfg: &cfg,
live: LiveParams {
cursor_model3d: model3d,
cursor_bounce_scale: MODEL_CLICK_BOUNCE_REF,
cursor_motion_blur: 1.0,
..LiveParams::default()
},
scene: Some(&scene),
track: &track,
t,
},
)
};
for model3d in [false, true] {
assert!(plan(model3d, 1.05).is_some(), "model3d {model3d} : visible avant");
for t in [1.1, 1.2, 1.4, 1.49] {
assert!(plan(model3d, t).is_none(), "model3d {model3d} : dessiné caché à {t} s");
}
let back = plan(model3d, 1.505).expect("réapparu");
assert_eq!(back.taps, 1, "model3d {model3d} : traînée venue de la phase cachée");
}
assert!(!plan(true, 1.08).expect("visible").impacts.is_empty(), "garde : l'anneau du clic");
}

/// Sous un angle fixe, l'écran tourne autour du centre de sa boîte ZOOMÉE, réduit de son
/// containment : le curseur y perdait ~18 % au point de focus même, pendant que le zoom
/// grossissait l'image. Il y garde sa taille à plat, à la perspective de biais près ; ailleurs,
Expand Down
Loading
Loading