From 46f243211e96682281b4ebb15c34729b14ce32be Mon Sep 17 00:00:00 2001 From: Joel Reymont <18791+joelreymont@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:01:17 +0200 Subject: [PATCH 1/2] Use TimeHistogramPerTimeline for time-stepping (#7084) --- crates/store/re_entity_db/src/entity_db.rs | 29 +- crates/store/re_entity_db/src/lib.rs | 2 - .../re_entity_db/src/times_per_timeline.rs | 163 ---- .../blueprint/archetypes/panel_blueprint.fbs | 3 + .../archetypes/time_panel_blueprint.rs | 43 +- crates/store/re_types/src/reflection/mod.rs | 7 + crates/utils/re_int_histogram/src/tree.rs | 136 ++++ crates/viewer/re_test_context/src/lib.rs | 9 +- .../re_time_panel/src/time_control_ui.rs | 32 +- crates/viewer/re_time_panel/src/time_panel.rs | 20 +- .../re_view_time_series/src/view_class.rs | 11 +- crates/viewer/re_viewer/src/app.rs | 30 +- crates/viewer/re_viewer/src/app_state.rs | 2 +- .../re_viewer_context/src/time_control.rs | 756 ++++++++++++------ .../archetypes/time_panel_blueprint.cpp | 16 +- .../archetypes/time_panel_blueprint.hpp | 15 + .../archetypes/time_panel_blueprint.py | 18 + 17 files changed, 833 insertions(+), 459 deletions(-) delete mode 100644 crates/store/re_entity_db/src/times_per_timeline.rs diff --git a/crates/store/re_entity_db/src/entity_db.rs b/crates/store/re_entity_db/src/entity_db.rs index e916d638dd4e..3941f77ab6c5 100644 --- a/crates/store/re_entity_db/src/entity_db.rs +++ b/crates/store/re_entity_db/src/entity_db.rs @@ -21,7 +21,7 @@ use re_query::{ }; use re_smart_channel::SmartChannelSource; -use crate::{Error, TimesPerTimeline, ingestion_statistics::IngestionStatistics}; +use crate::{Error, ingestion_statistics::IngestionStatistics}; // ---------------------------------------------------------------------------- @@ -86,17 +86,9 @@ pub struct EntityDb { /// In many places we just store the hashes, so we need a way to translate back. entity_path_from_hash: IntMap, - /// The global-scope time tracker. - /// - /// For each timeline, keeps track of what times exist, recursively across all - /// entities/components. - /// - /// Used for time control. - /// - /// TODO(#7084): Get rid of [`TimesPerTimeline`] and implement time-stepping with [`crate::TimeHistogram`] instead. - times_per_timeline: TimesPerTimeline, - /// A time histogram of all entities, for every timeline. + /// + /// Used for time control and gap detection. time_histogram_per_timeline: crate::TimeHistogramPerTimeline, /// A tree-view (split on path components) of the entities. @@ -146,7 +138,6 @@ impl EntityDb { last_modified_at: web_time::Instant::now(), latest_row_id: None, entity_path_from_hash: Default::default(), - times_per_timeline: Default::default(), tree: crate::EntityTree::root(), time_histogram_per_timeline: Default::default(), storage_engine, @@ -460,9 +451,6 @@ impl EntityDb { self.storage_engine().store().timelines() } - pub fn times_per_timeline(&self) -> &TimesPerTimeline { - &self.times_per_timeline - } pub fn has_any_data_on_timeline(&self, timeline: &TimelineName) -> bool { self.time_histogram_per_timeline @@ -483,6 +471,11 @@ impl EntityDb { self.time_histogram_per_timeline.get(timeline) } + /// Histogram of all entities and events + pub fn time_histogram_per_timeline(&self) -> &crate::TimeHistogramPerTimeline { + &self.time_histogram_per_timeline + } + #[inline] pub fn num_rows(&self) -> u64 { self.storage_engine.read().store().stats().total().num_rows @@ -600,7 +593,6 @@ impl EntityDb { { // Update our internal views by notifying them of resulting [`ChunkStoreEvent`]s. - self.times_per_timeline.on_events(&store_events); self.time_histogram_per_timeline.on_events(&store_events); self.tree.on_store_additions(&store_events); @@ -675,7 +667,6 @@ impl EntityDb { ); Self::on_store_deletions( - &mut self.times_per_timeline, &mut self.time_histogram_per_timeline, &mut self.tree, engine, @@ -699,7 +690,6 @@ impl EntityDb { let store_events = engine.store().drop_time_range(timeline, drop_range); Self::on_store_deletions( - &mut self.times_per_timeline, &mut self.time_histogram_per_timeline, &mut self.tree, engine, @@ -721,7 +711,6 @@ impl EntityDb { let store_events = engine.store().drop_entity_path(entity_path); Self::on_store_deletions( - &mut self.times_per_timeline, &mut self.time_histogram_per_timeline, &mut self.tree, engine, @@ -749,14 +738,12 @@ impl EntityDb { // NOTE: Parameters deconstructed instead of taking `self`, because borrowck cannot understand // partial borrows on methods. fn on_store_deletions( - times_per_timeline: &mut TimesPerTimeline, time_histogram_per_timeline: &mut crate::TimeHistogramPerTimeline, tree: &mut crate::EntityTree, mut engine: StorageEngineWriteGuard<'_>, store_events: &[ChunkStoreEvent], ) { engine.cache().on_events(store_events); - times_per_timeline.on_events(store_events); time_histogram_per_timeline.on_events(store_events); let engine = engine.downgrade(); diff --git a/crates/store/re_entity_db/src/lib.rs b/crates/store/re_entity_db/src/lib.rs index 557fa21cff2a..fc4e550e2dc0 100644 --- a/crates/store/re_entity_db/src/lib.rs +++ b/crates/store/re_entity_db/src/lib.rs @@ -10,7 +10,6 @@ mod ingestion_statistics; mod instance_path; mod store_bundle; mod time_histogram_per_timeline; -mod times_per_timeline; mod versioned_instance_path; pub use self::{ @@ -20,7 +19,6 @@ pub use self::{ instance_path::{InstancePath, InstancePathHash}, store_bundle::{StoreBundle, StoreLoadError}, time_histogram_per_timeline::{TimeHistogram, TimeHistogramPerTimeline}, - times_per_timeline::{TimeCounts, TimelineStats, TimesPerTimeline}, versioned_instance_path::{VersionedInstancePath, VersionedInstancePathHash}, }; diff --git a/crates/store/re_entity_db/src/times_per_timeline.rs b/crates/store/re_entity_db/src/times_per_timeline.rs deleted file mode 100644 index 4fd222420ad1..000000000000 --- a/crates/store/re_entity_db/src/times_per_timeline.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::collections::BTreeMap; - -use re_chunk::TimelineName; -use re_chunk_store::{ChunkStoreEvent, ChunkStoreSubscriber}; -use re_log_types::{TimeInt, Timeline}; - -// --- - -pub type TimeCounts = BTreeMap; - -#[derive(Clone, Debug)] -pub struct TimelineStats { - pub timeline: Timeline, - pub per_time: TimeCounts, - pub total_count: u64, -} - -impl TimelineStats { - pub fn new(timeline: Timeline) -> Self { - Self { - timeline, - per_time: Default::default(), - total_count: 0, - } - } - - pub fn num_events(&self) -> u64 { - debug_assert_eq!( - self.per_time.values().sum::(), - self.total_count, - "[DEBUG ASSERT] book keeping drifted" - ); - self.total_count - } -} - -/// A [`ChunkStoreSubscriber`] that keeps track of all unique timestamps on each [`Timeline`]. -/// -/// TODO(#7084): Get rid of [`TimesPerTimeline`] and implement time-stepping with [`crate::TimeHistogram`] instead. -#[derive(Clone)] -pub struct TimesPerTimeline(BTreeMap); - -impl std::ops::Deref for TimesPerTimeline { - type Target = BTreeMap; - - #[inline] - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl TimesPerTimeline { - #[inline] - pub fn timelines(&self) -> impl ExactSizeIterator + '_ { - self.0.values().map(|stats| &stats.timeline) - } - - #[inline] - pub fn timelines_with_stats(&self) -> impl ExactSizeIterator + '_ { - self.0.values() - } -} - -// Always ensure we have a default "log_time" timeline. -impl Default for TimesPerTimeline { - fn default() -> Self { - let timeline = Timeline::log_time(); - Self(BTreeMap::from([( - *timeline.name(), - TimelineStats::new(timeline), - )])) - } -} - -impl ChunkStoreSubscriber for TimesPerTimeline { - #[inline] - fn name(&self) -> String { - "rerun.store_subscriber.TimesPerTimeline".into() - } - - #[inline] - fn as_any(&self) -> &dyn std::any::Any { - self - } - - #[inline] - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - #[inline] - fn on_events(&mut self, events: &[ChunkStoreEvent]) { - re_tracing::profile_function!(format!("num_events={}", events.len())); - - for event in events { - for (&timeline, time_column) in event.chunk.timelines() { - let stats = self - .0 - .entry(timeline) - .or_insert_with(|| TimelineStats::new(*time_column.timeline())); - - for time in time_column.times() { - let count = stats.per_time.entry(time).or_default(); - let total_count = &mut stats.total_count; - - let delta = event.delta(); - - if delta < 0 { - *count = count.checked_sub(delta.unsigned_abs()).unwrap_or_else(|| { - re_log::debug!( - store_id = ?event.store_id, - entity_path = %event.chunk.entity_path(), - current = count, - removed = delta.unsigned_abs(), - "per `TimeInt` book keeping underflowed" - ); - u64::MIN - }); - *total_count = total_count - .checked_sub(delta.unsigned_abs()) - .unwrap_or_else(|| { - re_log::debug!( - store_id = ?event.store_id, - entity_path = %event.chunk.entity_path(), - current = total_count, - removed = delta.unsigned_abs(), - "total book keeping underflowed" - ); - u64::MIN - }); - } else { - *count = count.checked_add(delta.unsigned_abs()).unwrap_or_else(|| { - re_log::debug!( - store_id = ?event.store_id, - entity_path = %event.chunk.entity_path(), - current = count, - removed = delta.unsigned_abs(), - "per `TimeInt` book keeping overflowed" - ); - u64::MAX - }); - *total_count = total_count - .checked_add(delta.unsigned_abs()) - .unwrap_or_else(|| { - re_log::debug!( - store_id = ?event.store_id, - entity_path = %event.chunk.entity_path(), - current = total_count, - removed = delta.unsigned_abs(), - "total book keeping underflowed" - ); - u64::MAX - }); - } - - if *count == 0 { - stats.per_time.remove(&time); - } - } - } - } - } -} diff --git a/crates/store/re_types/definitions/rerun/blueprint/archetypes/panel_blueprint.fbs b/crates/store/re_types/definitions/rerun/blueprint/archetypes/panel_blueprint.fbs index 7eaaee271c05..d0379bf6188e 100644 --- a/crates/store/re_types/definitions/rerun/blueprint/archetypes/panel_blueprint.fbs +++ b/crates/store/re_types/definitions/rerun/blueprint/archetypes/panel_blueprint.fbs @@ -32,6 +32,9 @@ table TimePanelBlueprint ( /// What timeline the panel is on. timeline: rerun.blueprint.components.TimelineName ("attr.rerun.component_optional", nullable, order: 2000); + /// What time the time cursor should be on. + time: rerun.blueprint.components.TimeInt ("attr.rerun.component_optional", nullable, order: 2100); + /// A time playback speed multiplier. playback_speed: rerun.blueprint.components.PlaybackSpeed ("attr.rerun.component_optional", nullable, order: 2200); diff --git a/crates/store/re_types/src/blueprint/archetypes/time_panel_blueprint.rs b/crates/store/re_types/src/blueprint/archetypes/time_panel_blueprint.rs index 9ef7182bc4a4..445c95cb2033 100644 --- a/crates/store/re_types/src/blueprint/archetypes/time_panel_blueprint.rs +++ b/crates/store/re_types/src/blueprint/archetypes/time_panel_blueprint.rs @@ -32,6 +32,9 @@ pub struct TimePanelBlueprint { /// What timeline the panel is on. pub timeline: Option, + /// What time the time cursor should be on. + pub time: Option, + /// A time playback speed multiplier. pub playback_speed: Option, @@ -77,6 +80,18 @@ impl TimePanelBlueprint { } } + /// Returns the [`ComponentDescriptor`] for [`Self::time`]. + /// + /// The corresponding component is [`crate::blueprint::components::TimeInt`]. + #[inline] + pub fn descriptor_time() -> ComponentDescriptor { + ComponentDescriptor { + archetype: Some("rerun.blueprint.archetypes.TimePanelBlueprint".into()), + component: "TimePanelBlueprint:time".into(), + component_type: Some("rerun.blueprint.components.TimeInt".into()), + } + } + /// Returns the [`ComponentDescriptor`] for [`Self::playback_speed`]. /// /// The corresponding component is [`crate::blueprint::components::PlaybackSpeed`]. @@ -144,11 +159,12 @@ static REQUIRED_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 0usize]> = static RECOMMENDED_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 0usize]> = std::sync::LazyLock::new(|| []); -static OPTIONAL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 7usize]> = +static OPTIONAL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 8usize]> = std::sync::LazyLock::new(|| { [ TimePanelBlueprint::descriptor_state(), TimePanelBlueprint::descriptor_timeline(), + TimePanelBlueprint::descriptor_time(), TimePanelBlueprint::descriptor_playback_speed(), TimePanelBlueprint::descriptor_fps(), TimePanelBlueprint::descriptor_play_state(), @@ -157,11 +173,12 @@ static OPTIONAL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 7usize]> = ] }); -static ALL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 7usize]> = +static ALL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 8usize]> = std::sync::LazyLock::new(|| { [ TimePanelBlueprint::descriptor_state(), TimePanelBlueprint::descriptor_timeline(), + TimePanelBlueprint::descriptor_time(), TimePanelBlueprint::descriptor_playback_speed(), TimePanelBlueprint::descriptor_fps(), TimePanelBlueprint::descriptor_play_state(), @@ -171,8 +188,8 @@ static ALL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 7usize]> = }); impl TimePanelBlueprint { - /// The total number of components in the archetype: 0 required, 0 recommended, 7 optional - pub const NUM_COMPONENTS: usize = 7usize; + /// The total number of components in the archetype: 0 required, 0 recommended, 8 optional + pub const NUM_COMPONENTS: usize = 8usize; } impl ::re_types_core::Archetype for TimePanelBlueprint { @@ -219,6 +236,9 @@ impl ::re_types_core::Archetype for TimePanelBlueprint { let timeline = arrays_by_descr .get(&Self::descriptor_timeline()) .map(|array| SerializedComponentBatch::new(array.clone(), Self::descriptor_timeline())); + let time = arrays_by_descr + .get(&Self::descriptor_time()) + .map(|array| SerializedComponentBatch::new(array.clone(), Self::descriptor_time())); let playback_speed = arrays_by_descr .get(&Self::descriptor_playback_speed()) .map(|array| { @@ -245,6 +265,7 @@ impl ::re_types_core::Archetype for TimePanelBlueprint { Ok(Self { state, timeline, + time, playback_speed, fps, play_state, @@ -261,6 +282,7 @@ impl ::re_types_core::AsComponents for TimePanelBlueprint { [ self.state.clone(), self.timeline.clone(), + self.time.clone(), self.playback_speed.clone(), self.fps.clone(), self.play_state.clone(), @@ -282,6 +304,7 @@ impl TimePanelBlueprint { Self { state: None, timeline: None, + time: None, playback_speed: None, fps: None, play_state: None, @@ -309,6 +332,10 @@ impl TimePanelBlueprint { crate::blueprint::components::TimelineName::arrow_empty(), Self::descriptor_timeline(), )), + time: Some(SerializedComponentBatch::new( + crate::blueprint::components::TimeInt::arrow_empty(), + Self::descriptor_time(), + )), playback_speed: Some(SerializedComponentBatch::new( crate::blueprint::components::PlaybackSpeed::arrow_empty(), Self::descriptor_playback_speed(), @@ -352,6 +379,13 @@ impl TimePanelBlueprint { self } + /// What time the time cursor should be on. + #[inline] + pub fn with_time(mut self, time: impl Into) -> Self { + self.time = try_serialize_field(Self::descriptor_time(), [time]); + self + } + /// A time playback speed multiplier. #[inline] pub fn with_playback_speed( @@ -411,6 +445,7 @@ impl ::re_byte_size::SizeBytes for TimePanelBlueprint { fn heap_size_bytes(&self) -> u64 { self.state.heap_size_bytes() + self.timeline.heap_size_bytes() + + self.time.heap_size_bytes() + self.playback_speed.heap_size_bytes() + self.fps.heap_size_bytes() + self.play_state.heap_size_bytes() diff --git a/crates/store/re_types/src/reflection/mod.rs b/crates/store/re_types/src/reflection/mod.rs index a58b750a733b..d6adee93a6a1 100644 --- a/crates/store/re_types/src/reflection/mod.rs +++ b/crates/store/re_types/src/reflection/mod.rs @@ -4034,6 +4034,13 @@ fn generate_archetype_reflection() -> ArchetypeReflectionMap { docstring_md: "What timeline the panel is on.", is_required: false, }, + ArchetypeFieldReflection { + name: "time", + display_name: "Time", + component_type: "rerun.blueprint.components.TimeInt".into(), + docstring_md: "What time the time cursor should be on.", + is_required: false, + }, ArchetypeFieldReflection { name: "playback_speed", display_name: "Playback speed", diff --git a/crates/utils/re_int_histogram/src/tree.rs b/crates/utils/re_int_histogram/src/tree.rs index 118beb13c35f..3df084b50b3f 100644 --- a/crates/utils/re_int_histogram/src/tree.rs +++ b/crates/utils/re_int_histogram/src/tree.rs @@ -232,6 +232,61 @@ impl Int64Histogram { }, } } + + /// Find the next key greater than the given time. + /// + /// If found, returns that key. Otherwise wraps around and returns the minimum key. + /// Returns `None` only if the histogram is empty. + pub fn next_key_after(&self, time: i64) -> Option { + // Use cutoff_size=1 to get individual keys + if let Some((range, _)) = self + .range( + (std::ops::Bound::Excluded(time), std::ops::Bound::Unbounded), + 1, + ) + .next() + { + Some(range.min) + } else { + // Wrap around to the minimum key + self.min_key() + } + } + + /// Find the previous key less than the given time. + /// + /// If found, returns that key. Otherwise wraps around and returns the maximum key. + /// Returns `None` only if the histogram is empty. + pub fn prev_key_before(&self, time: i64) -> Option { + // Fast path: if the maximum key is less than time, we can return it directly + // This is O(log n) and avoids iterating through ranges + if let Some(max) = self.max_key() { + if max < time { + return Some(max); + } + } else { + // Empty histogram + return None; + } + + // Optimization: Use a larger cutoff_size to reduce the number of ranges we iterate through. + // With cutoff_size=1024, we get ranges up to 1024 keys long, which dramatically reduces + // the number of iterations for sparse histograms. + // According to the documentation, the ends (min/max) of returned ranges are guaranteed + // to be keys with non-zero count, so the max of the last range is the correct answer. + let mut last_range_max = None; + for (range, _) in self.range( + (std::ops::Bound::Unbounded, std::ops::Bound::Excluded(time)), + 1024, + ) { + last_range_max = Some(range.max); + } + + last_range_max.or_else(|| { + // No keys before time, wrap around to max + self.max_key() + }) + } } /// An iterator over an [`Int64Histogram`]. @@ -1073,4 +1128,85 @@ mod tests { assert_eq!((set.min_key(), set.max_key()), (None, None)); assert_eq!(set.range(.., 1).count(), 0); } + + #[test] + fn test_next_key_after() { + let mut hist = Int64Histogram::default(); + + // Empty histogram + assert_eq!(hist.next_key_after(0), None); + + // Single key + hist.increment(10, 1); + assert_eq!(hist.next_key_after(5), Some(10)); + assert_eq!(hist.next_key_after(10), Some(10)); // wraps around + assert_eq!(hist.next_key_after(15), Some(10)); // wraps around + + // Multiple keys + hist.increment(20, 1); + hist.increment(30, 1); + assert_eq!(hist.next_key_after(5), Some(10)); + assert_eq!(hist.next_key_after(10), Some(20)); + assert_eq!(hist.next_key_after(15), Some(20)); + assert_eq!(hist.next_key_after(25), Some(30)); + assert_eq!(hist.next_key_after(30), Some(10)); // wraps around + assert_eq!(hist.next_key_after(35), Some(10)); // wraps around + + // Sparse keys + hist = Int64Histogram::default(); + hist.increment(1000, 1); + hist.increment(2000, 1); + hist.increment(3000, 1); + assert_eq!(hist.next_key_after(500), Some(1000)); + assert_eq!(hist.next_key_after(1500), Some(2000)); + assert_eq!(hist.next_key_after(2500), Some(3000)); + assert_eq!(hist.next_key_after(3500), Some(1000)); // wraps around + } + + #[test] + fn test_prev_key_before() { + let mut hist = Int64Histogram::default(); + + // Empty histogram + assert_eq!(hist.prev_key_before(0), None); + + // Single key + hist.increment(10, 1); + assert_eq!(hist.prev_key_before(15), Some(10)); + assert_eq!(hist.prev_key_before(10), Some(10)); // wraps around + assert_eq!(hist.prev_key_before(5), Some(10)); // wraps around + + // Multiple keys + hist.increment(20, 1); + hist.increment(30, 1); + assert_eq!(hist.prev_key_before(35), Some(30)); + assert_eq!(hist.prev_key_before(30), Some(20)); + assert_eq!(hist.prev_key_before(25), Some(20)); + assert_eq!(hist.prev_key_before(15), Some(10)); + assert_eq!(hist.prev_key_before(10), Some(30)); // wraps around + assert_eq!(hist.prev_key_before(5), Some(30)); // wraps around + + // Sparse keys + hist = Int64Histogram::default(); + hist.increment(1000, 1); + hist.increment(2000, 1); + hist.increment(3000, 1); + assert_eq!(hist.prev_key_before(3500), Some(3000)); + assert_eq!(hist.prev_key_before(2500), Some(2000)); + assert_eq!(hist.prev_key_before(1500), Some(1000)); + assert_eq!(hist.prev_key_before(500), Some(3000)); // wraps around + + // Fast path: max_key < time + assert_eq!(hist.max_key(), Some(3000)); + assert_eq!(hist.prev_key_before(5000), Some(3000)); + + // Dense histogram with many keys (tests optimization) + hist = Int64Histogram::default(); + for i in 0..1000 { + hist.increment(i, 1); + } + assert_eq!(hist.prev_key_before(500), Some(499)); + assert_eq!(hist.prev_key_before(1000), Some(999)); + assert_eq!(hist.prev_key_before(0), Some(999)); // wraps around + } } diff --git a/crates/viewer/re_test_context/src/lib.rs b/crates/viewer/re_test_context/src/lib.rs index 234a3c41b497..2377585f3016 100644 --- a/crates/viewer/re_test_context/src/lib.rs +++ b/crates/viewer/re_test_context/src/lib.rs @@ -715,11 +715,11 @@ impl TestContext { } => { self.with_blueprint_ctx(|blueprint_ctx, hub| { let mut time_ctrl = self.time_ctrl.write(); - let times_per_timeline = hub + let store = hub .store_bundle() .get(&store_id) - .expect("Invalid store id in `SystemCommand::TimeControlCommands`") - .times_per_timeline(); + .expect("Invalid store id in `SystemCommand::TimeControlCommands`"); + let timelines = store.timelines(); let blueprint_ctx = Some(&blueprint_ctx).filter(|_| store_id.is_recording()); @@ -727,7 +727,8 @@ impl TestContext { // We can ignore the response in the test context. let res = time_ctrl.handle_time_commands( blueprint_ctx, - times_per_timeline, + store.time_histogram_per_timeline(), + &timelines, &time_commands, ); diff --git a/crates/viewer/re_time_panel/src/time_control_ui.rs b/crates/viewer/re_time_panel/src/time_control_ui.rs index e79f79a20be9..bec7278f5dd3 100644 --- a/crates/viewer/re_time_panel/src/time_control_ui.rs +++ b/crates/viewer/re_time_panel/src/time_control_ui.rs @@ -1,7 +1,8 @@ use egui::NumExt as _; -use re_entity_db::TimesPerTimeline; -use re_log_types::TimeType; +use re_chunk_store::external::re_chunk; +use re_entity_db::TimeHistogramPerTimeline; +use re_log_types::{TimeType, Timeline}; use re_types::blueprint::components::{LoopMode, PlayState}; use re_ui::{UICommand, UiExt as _, list_item}; @@ -15,7 +16,8 @@ impl TimeControlUi { pub fn timeline_selector_ui( &self, time_ctrl: &TimeControl, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, ui: &mut egui::Ui, time_commands: &mut Vec, ) { @@ -28,8 +30,26 @@ impl TimeControlUi { let response = egui::ComboBox::from_id_salt("timeline") .selected_text(time_ctrl.timeline().name().as_str()) .show_ui(ui, |ui| { - for timeline_stats in times_per_timeline.timelines_with_stats() { - let timeline = &timeline_stats.timeline; + // Ensure default timelines (log_time, log_tick) are always shown, even before data arrives + let default_timelines = [ + Timeline::log_time(), + Timeline::log_tick(), + ]; + + let mut all_timelines: std::collections::BTreeMap<_, _> = default_timelines + .iter() + .map(|t| (*t.name(), *t)) + .collect(); + + // Add any custom timelines from logged data + all_timelines.extend(timelines.iter().map(|(k, v)| (*k, *v))); + + for (timeline_name, timeline) in all_timelines.iter() { + let event_count = time_histogram_per_timeline + .get(timeline_name) + .map(|hist| hist.total_count()) + .unwrap_or(0); + if ui .selectable_label( timeline == time_ctrl.timeline(), @@ -38,7 +58,7 @@ impl TimeControlUi { egui::Atom::grow(), egui::RichText::new(format!( "{} events", - re_format::format_uint(timeline_stats.num_events()) + re_format::format_uint(event_count) )) .size(10.0) .color(ui.tokens().text_subdued), diff --git a/crates/viewer/re_time_panel/src/time_panel.rs b/crates/viewer/re_time_panel/src/time_panel.rs index a6f7cbd318d4..d38ecd9d02eb 100644 --- a/crates/viewer/re_time_panel/src/time_panel.rs +++ b/crates/viewer/re_time_panel/src/time_panel.rs @@ -367,9 +367,11 @@ impl TimePanel { }); } ui.horizontal(|ui| { + let timelines = entity_db.timelines(); self.time_control_ui.timeline_selector_ui( time_ctrl, - entity_db.times_per_timeline(), + entity_db.time_histogram_per_timeline(), + &timelines, ui, time_commands, ); @@ -384,7 +386,8 @@ impl TimePanel { }); } else { // One row: - let times_per_timeline = entity_db.times_per_timeline(); + let timelines = entity_db.timelines(); + let time_histogram_per_timeline = entity_db.time_histogram_per_timeline(); if has_more_than_one_time_point { self.time_control_ui @@ -393,7 +396,8 @@ impl TimePanel { self.time_control_ui.timeline_selector_ui( time_ctrl, - times_per_timeline, + time_histogram_per_timeline, + &timelines, ui, time_commands, ); @@ -1292,9 +1296,11 @@ impl TimePanel { self.time_control_ui.fps_ui(time_ctrl, ui, time_commands); }); ui.horizontal(|ui| { + let timelines = entity_db.timelines(); self.time_control_ui.timeline_selector_ui( time_ctrl, - entity_db.times_per_timeline(), + entity_db.time_histogram_per_timeline(), + &timelines, ui, time_commands, ); @@ -1308,13 +1314,15 @@ impl TimePanel { }); } else { // One row: - let times_per_timeline = entity_db.times_per_timeline(); + let timelines = entity_db.timelines(); + let time_histogram_per_timeline = entity_db.time_histogram_per_timeline(); self.time_control_ui .play_pause_ui(time_ctrl, ui, time_commands); self.time_control_ui.timeline_selector_ui( time_ctrl, - times_per_timeline, + time_histogram_per_timeline, + &timelines, ui, time_commands, ); diff --git a/crates/viewer/re_view_time_series/src/view_class.rs b/crates/viewer/re_view_time_series/src/view_class.rs index 4faa709f1225..be7df30447ae 100644 --- a/crates/viewer/re_view_time_series/src/view_class.rs +++ b/crates/viewer/re_view_time_series/src/view_class.rs @@ -196,13 +196,14 @@ impl ViewClass for TimeSeriesView { system_registry.register_fallback_provider( TimeAxis::descriptor_view_range().component, |ctx| { - let times_per_timeline = ctx.viewer_ctx().recording().times_per_timeline(); - let (timeline_min, timeline_max) = times_per_timeline + let time_histogram_per_timeline = + ctx.viewer_ctx().recording().time_histogram_per_timeline(); + let (timeline_min, timeline_max) = time_histogram_per_timeline .get(ctx.viewer_ctx().time_ctrl.timeline().name()) - .and_then(|stats| { + .and_then(|hist| { Some(( - *stats.per_time.keys().next()?, - *stats.per_time.keys().next_back()?, + re_log_types::TimeInt::new_temporal(hist.min_key()?), + re_log_types::TimeInt::new_temporal(hist.max_key()?), )) }) .unzip(); diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index 7c90c34e99d9..d9bc45c01457 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -542,12 +542,16 @@ impl App { // If there's no active recording, we should not trigger any callbacks, but since there's an active recording here, // we want to diff state changes. let should_diff_state = true; + let timelines = recording.timelines(); + let time_now = self.egui_ctx.input(|i| i.time); let response = time_ctrl.update( - recording.times_per_timeline(), + recording.time_histogram_per_timeline(), + &timelines, dt, more_data_is_coming, should_diff_state, Some(&bp_ctx), + time_now, ); if response.needs_repaint == NeedsRepaint::Yes { @@ -567,13 +571,19 @@ impl App { playing_change: _, timeline_change: _, time_change: _, - } = self.state.blueprint_time_control.update( - bp_ctx.current_blueprint.times_per_timeline(), - dt, - more_data_is_coming, - should_diff_state, - None::<&AppBlueprintCtx<'_>>, - ); + } = { + let timelines = bp_ctx.current_blueprint.timelines(); + let time_now = self.egui_ctx.input(|i| i.time); + self.state.blueprint_time_control.update( + bp_ctx.current_blueprint.time_histogram_per_timeline(), + &timelines, + dt, + more_data_is_coming, + should_diff_state, + None::<&AppBlueprintCtx<'_>>, + time_now, + ) + }; if needs_repaint == NeedsRepaint::Yes { self.egui_ctx.request_repaint(); @@ -771,9 +781,11 @@ impl App { return; }; + let timelines = target_store.timelines(); let response = time_ctrl.handle_time_commands( blueprint_ctx.as_ref(), - target_store.times_per_timeline(), + target_store.time_histogram_per_timeline(), + &timelines, &time_commands, ); diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index 06eb5ffb6786..a433e4aef3b1 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -927,7 +927,7 @@ pub(crate) fn create_time_control_for<'cfgs>( let mut time_ctrl = TimeControl::from_blueprint(blueprint_ctx); time_ctrl.set_play_state( - Some(entity_db.times_per_timeline()), + entity_db.time_histogram_per_timeline(), play_state, Some(blueprint_ctx), ); diff --git a/crates/viewer/re_viewer_context/src/time_control.rs b/crates/viewer/re_viewer_context/src/time_control.rs index 39ec55706f9e..c68fbe74ea33 100644 --- a/crates/viewer/re_viewer_context/src/time_control.rs +++ b/crates/viewer/re_viewer_context/src/time_control.rs @@ -8,7 +8,7 @@ use re_types::blueprint::{ use vec1::Vec1; use re_chunk::{EntityPath, TimelineName}; -use re_entity_db::{TimeCounts, TimelineStats, TimesPerTimeline}; +use re_entity_db::{TimeHistogram, TimeHistogramPerTimeline}; use re_log_types::{ AbsoluteTimeRange, AbsoluteTimeRangeF, Duration, TimeCell, TimeInt, TimeReal, TimeType, Timeline, @@ -24,6 +24,10 @@ pub fn time_panel_blueprint_entity_path() -> EntityPath { /// Helper trait to write time panel related blueprint components. trait TimeBlueprintExt { + fn set_time(&self, time: impl Into); + + fn time(&self) -> Option; + fn set_timeline(&self, timeline: TimelineName); fn timeline(&self) -> Option; @@ -31,6 +35,11 @@ trait TimeBlueprintExt { /// Replaces the current timeline with the automatic one. fn clear_timeline(&self); + /// Clears the blueprint time cursor, and will instead fall back + /// to a default one, most likely the one saved in time control's + /// per timeline state. + fn clear_time(&self); + fn set_playback_speed(&self, playback_speed: f64); fn playback_speed(&self) -> Option; @@ -49,12 +58,34 @@ trait TimeBlueprintExt { } impl TimeBlueprintExt for T { + fn set_time(&self, time: impl Into) { + let time: TimeInt = time.into(); + self.save_static_blueprint_component( + time_panel_blueprint_entity_path(), + &TimePanelBlueprint::descriptor_time(), + &re_types::blueprint::components::TimeInt(time.as_i64().into()), + ); + } + + fn time(&self) -> Option { + let (_, time) = self + .current_blueprint() + .latest_at_component_quiet::( + &time_panel_blueprint_entity_path(), + self.blueprint_query(), + TimePanelBlueprint::descriptor_time().component, + )?; + + Some(TimeInt::saturated_temporal_i64(time.0.0)) + } + fn set_timeline(&self, timeline: TimelineName) { self.save_blueprint_component( time_panel_blueprint_entity_path(), &TimePanelBlueprint::descriptor_timeline(), &re_types::blueprint::components::TimelineName::from(timeline.as_str()), ); + self.clear_time(); } fn timeline(&self) -> Option { @@ -76,6 +107,13 @@ impl TimeBlueprintExt for T { ); } + fn clear_time(&self) { + self.clear_static_blueprint_component( + time_panel_blueprint_entity_path(), + TimePanelBlueprint::descriptor_time(), + ); + } + fn set_playback_speed(&self, playback_speed: f64) { self.save_blueprint_component( time_panel_blueprint_entity_path(), @@ -369,6 +407,12 @@ pub struct TimeControl { loop_mode: LoopMode, + /// Last time we synced the playhead to the blueprint (in seconds since startup). + /// + /// We throttle blueprint writes during playback to avoid spam while still keeping + /// multi-client viewers synchronized. + last_blueprint_sync: Option, + /// Range with special highlight. /// /// This is used during UI interactions. E.g. to show visual history range that's highlighted. @@ -377,14 +421,17 @@ pub struct TimeControl { impl Default for TimeControl { fn default() -> Self { + let empty_hist = TimeHistogramPerTimeline::default(); + let empty_timelines = std::collections::BTreeMap::new(); Self { - timeline: ActiveTimeline::Auto(default_timeline([])), + timeline: ActiveTimeline::Auto(default_timeline(&empty_hist, &empty_timelines)), states: Default::default(), valid_time_ranges: Default::default(), playing: true, following: true, speed: 1.0, loop_mode: LoopMode::Off, + last_blueprint_sync: None, highlighted_range: None, } } @@ -429,20 +476,27 @@ impl TimeControl { pub fn from_blueprint(blueprint_ctx: &impl BlueprintContext) -> Self { let mut this = Self::default(); - this.update_from_blueprint(blueprint_ctx, None); + this.update_from_blueprint(blueprint_ctx, None, &std::collections::BTreeMap::new()); this } /// Read from the time panel blueprint and update the state from that. /// - /// If `times_per_timeline` is some this will also make sure we are on + /// If `time_histogram_per_timeline` is some this will also make sure we are on /// a valid timeline. pub fn update_from_blueprint( &mut self, blueprint_ctx: &impl BlueprintContext, - times_per_timeline: Option<&TimesPerTimeline>, + time_histogram_per_timeline: Option<&TimeHistogramPerTimeline>, + timelines: &std::collections::BTreeMap, ) { + let old_timeline = *self.timeline().name(); + + // Track if blueprint explicitly specifies a timeline. + // If both timeline and time are set in blueprint, we should honor both. + let blueprint_has_timeline = blueprint_ctx.timeline().is_some(); + if let Some(timeline) = blueprint_ctx.timeline() { if matches!(self.timeline, ActiveTimeline::Auto(_)) || timeline.as_str() != self.timeline().name().as_str() @@ -453,29 +507,101 @@ impl TimeControl { self.timeline = ActiveTimeline::Auto(*self.timeline()); } - let old_timeline = *self.timeline().name(); // Make sure we are on a valid timeline. - if let Some(times_per_timeline) = times_per_timeline { - self.select_valid_timeline(times_per_timeline); + if let Some(time_histogram_per_timeline) = time_histogram_per_timeline { + self.select_valid_timeline(time_histogram_per_timeline, timelines); } - // If we are on a new timeline insert that new state at the start. Or end if we're following. - else if let Some(times_per_timeline) = times_per_timeline - && let Some(full_valid_range) = self.full_valid_range(times_per_timeline) - { - self.states.insert( - *self.timeline.name(), - TimeState::new(if self.following { - full_valid_range.max + + // Capture timeline AFTER select_valid_timeline, which may have auto-switched it + let new_timeline = *self.timeline().name(); + + // When the timeline changes... + if old_timeline != new_timeline { + // If blueprint explicitly specified BOTH timeline AND time (e.g., saved layout with pinned cursor), + // honor the blueprint time even though the timeline changed. + if blueprint_has_timeline && blueprint_ctx.time().is_some() { + if let Some(time) = blueprint_ctx.time() { + if self.time_int() != Some(time) { + self.states + .entry(*self.timeline().name()) + .or_insert_with(|| TimeState::new(time)) + .time = time.into(); + } + } + } + // Otherwise (auto-switched timeline, or blueprint specified timeline but not time), + // restore from cached per-timeline state or use histogram bounds. + else { + if let Some(state) = self.states.get(&new_timeline) { + // Restore the new timeline's cached time to blueprint and state + blueprint_ctx.set_time(state.time.floor()); + } else { + // New timeline with no cached state: initialize based on available data + let initial_time = if let Some(time_histogram_per_timeline) = time_histogram_per_timeline + && let Some(full_valid_range) = self.full_valid_range(time_histogram_per_timeline) + { + if self.following { + full_valid_range.max + } else { + full_valid_range.min + } + } else { + // No data yet: start at beginning (TimeInt::MIN) + TimeInt::MIN + }; + self.states.insert(*self.timeline.name(), TimeState::new(initial_time)); + blueprint_ctx.set_time(initial_time); + } + } + + // Restore timeline-specific loop selection when timeline changes + if let Some(state) = self.states.get(&new_timeline) { + if let Some(loop_selection) = state.loop_selection { + blueprint_ctx.set_time_selection(AbsoluteTimeRange::new( + loop_selection.min.floor(), + loop_selection.max.floor(), + )); + } else { + blueprint_ctx.clear_time_selection(); + } + } + } + // Same timeline: sync blueprint time with current state + else { + if let Some(time) = blueprint_ctx.time() { + if self.time_int() != Some(time) { + self.states + .entry(*self.timeline().name()) + .or_insert_with(|| TimeState::new(time)) + .time = time.into(); + } + } else if let Some(state) = self.states.get(self.timeline().name()) { + blueprint_ctx.set_time(state.time.floor()); + } else { + // No state for current timeline yet: initialize based on available data + let initial_time = if let Some(time_histogram_per_timeline) = time_histogram_per_timeline + && let Some(full_valid_range) = self.full_valid_range(time_histogram_per_timeline) + { + if self.following { + full_valid_range.max + } else { + full_valid_range.min + } } else { - full_valid_range.min - }), - ); + // No data yet: start at beginning (TimeInt::MIN) + TimeInt::MIN + }; + self.states.insert(*self.timeline.name(), TimeState::new(initial_time)); + blueprint_ctx.set_time(initial_time); + } } if let Some(new_play_state) = blueprint_ctx.play_state() && new_play_state != self.play_state() { - self.set_play_state(times_per_timeline, new_play_state, Some(blueprint_ctx)); + if let Some(time_histogram_per_timeline) = time_histogram_per_timeline { + self.set_play_state(time_histogram_per_timeline, new_play_state, Some(blueprint_ctx)); + } } if let Some(new_loop_mode) = blueprint_ctx.loop_mode() { @@ -483,11 +609,13 @@ impl TimeControl { if self.loop_mode != LoopMode::Off { if self.play_state() == PlayState::Following { - self.set_play_state( - times_per_timeline, - PlayState::Playing, - Some(blueprint_ctx), - ); + if let Some(time_histogram_per_timeline) = time_histogram_per_timeline { + self.set_play_state( + time_histogram_per_timeline, + PlayState::Playing, + Some(blueprint_ctx), + ); + } } // It makes no sense with looping and follow. @@ -502,23 +630,13 @@ impl TimeControl { let play_state = self.play_state(); // Update the last paused time if we are paused. - let timeline = *self.timeline.name(); - if let Some(state) = self.states.get_mut(&timeline) { + if let Some(state) = self.states.get_mut(self.timeline.name()) { if let Some(fps) = blueprint_ctx.fps() { state.fps = fps as f32; } - let bp_loop_section = blueprint_ctx.time_selection(); - // If we've switched timeline, use the new timeline's cached time selection. - if old_timeline != timeline { - match state.loop_selection { - Some(selection) => blueprint_ctx.set_time_selection(selection.to_int()), - None => { - blueprint_ctx.clear_time_selection(); - } - } - } else { - state.loop_selection = bp_loop_section.map(|r| r.into()); + if let Some(new_time_selection) = blueprint_ctx.time_selection() { + state.loop_selection = Some(new_time_selection.into()); } match play_state { @@ -534,22 +652,42 @@ impl TimeControl { /// /// If `blueprint_ctx` is some, this will also update the time stored in /// the blueprint if `time_int` has changed. - fn update_time(&mut self, time: TimeReal) { - self.states + /// + /// During playback, writes are throttled to avoid per-frame spam while still + /// keeping multi-client viewers synchronized. + fn update_time(&mut self, blueprint_ctx: Option<&impl BlueprintContext>, time: TimeReal, time_now_seconds: f64) { + let state = self.states .entry(*self.timeline.name()) - .or_insert_with(|| TimeState::new(time)) - .time = time; + .or_insert_with(|| TimeState::new(time)); + state.time = time; + + // Throttle blueprint writes during playback: only write if it's been >100ms since last write + const BLUEPRINT_SYNC_INTERVAL_SECONDS: f64 = 0.1; + + if let Some(blueprint_ctx) = blueprint_ctx { + let should_sync = self.last_blueprint_sync + .map_or(true, |last| { + time_now_seconds - last >= BLUEPRINT_SYNC_INTERVAL_SECONDS + }); + + if should_sync { + blueprint_ctx.set_time(state.time.floor()); + self.last_blueprint_sync = Some(time_now_seconds); + } + } } /// Create [`TimeControlCommand`]s to move the time forward (if playing), and perhaps pause if /// we've reached the end. pub fn update( &mut self, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, stable_dt: f32, more_data_is_coming: bool, should_diff_state: bool, blueprint_ctx: Option<&impl BlueprintContext>, + time_now_seconds: f64, ) -> TimeControlResponse { let (old_playing, old_timeline, old_state) = ( self.playing, @@ -558,12 +696,12 @@ impl TimeControl { ); if let Some(blueprint_ctx) = blueprint_ctx { - self.update_from_blueprint(blueprint_ctx, Some(times_per_timeline)); + self.update_from_blueprint(blueprint_ctx, Some(time_histogram_per_timeline), timelines); } else { - self.select_valid_timeline(times_per_timeline); + self.select_valid_timeline(time_histogram_per_timeline, timelines); } - let Some(full_valid_range) = self.full_valid_range(times_per_timeline) else { + let Some(full_valid_range) = self.full_valid_range(time_histogram_per_timeline) else { return TimeControlResponse::no_repaint(); // we have no data on this timeline yet, so bail }; @@ -594,14 +732,15 @@ impl TimeControl { if self.loop_mode == LoopMode::Off && full_valid_range.max() <= state.time { // We've reached the end of the data - self.update_time(full_valid_range.max().into()); + self.update_time(blueprint_ctx, full_valid_range.max().into(), time_now_seconds); if more_data_is_coming { // then let's wait for it without pausing! return self.apply_state_diff_if_needed( TimeControlResponse::no_repaint(), // ui will wake up when more data arrives should_diff_state, - times_per_timeline, + time_histogram_per_timeline, + timelines, old_timeline, old_playing, old_state, @@ -652,13 +791,13 @@ impl TimeControl { new_time = new_time.clamp(clamp_range.min().into(), clamp_range.max().into()); } - self.update_time(new_time); + self.update_time(blueprint_ctx, new_time, time_now_seconds); NeedsRepaint::Yes } PlayState::Following => { // Set the time to the max: - self.update_time(full_valid_range.max().into()); + self.update_time(blueprint_ctx, full_valid_range.max().into(), time_now_seconds); NeedsRepaint::No // no need for request_repaint - we already repaint when new data arrives } @@ -667,7 +806,8 @@ impl TimeControl { self.apply_state_diff_if_needed( TimeControlResponse::new(needs_repaint), should_diff_state, - times_per_timeline, + time_histogram_per_timeline, + timelines, old_timeline, old_playing, old_state, @@ -679,7 +819,8 @@ impl TimeControl { &mut self, response: TimeControlResponse, should_diff_state: bool, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + _timelines: &std::collections::BTreeMap, old_timeline: Timeline, old_playing: bool, old_state: Option, @@ -687,9 +828,9 @@ impl TimeControl { let mut response = response; if should_diff_state - && times_per_timeline + && time_histogram_per_timeline .get(self.timeline.name()) - .is_some_and(|stats| !stats.per_time.is_empty()) + .is_some_and(|hist| !hist.is_empty()) { self.diff_with(&mut response, old_timeline, old_playing, old_state); } @@ -748,7 +889,8 @@ impl TimeControl { pub fn handle_time_commands( &mut self, blueprint_ctx: Option<&impl BlueprintContext>, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, commands: &[TimeControlCommand], ) -> TimeControlResponse { let mut response = TimeControlResponse { @@ -766,7 +908,7 @@ impl TimeControl { for command in commands { let needs_repaint = - self.handle_time_command(blueprint_ctx, times_per_timeline, command); + self.handle_time_command(blueprint_ctx, time_histogram_per_timeline, timelines, command); if needs_repaint == NeedsRepaint::Yes { response.needs_repaint = NeedsRepaint::Yes; @@ -787,7 +929,8 @@ impl TimeControl { fn handle_time_command( &mut self, blueprint_ctx: Option<&impl BlueprintContext>, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, command: &TimeControlCommand, ) -> NeedsRepaint { match command { @@ -821,23 +964,20 @@ impl TimeControl { blueprint_ctx.set_timeline(*timeline_name); } - if let Some(stats) = times_per_timeline.get(timeline_name) { - self.timeline = ActiveTimeline::UserEdited(stats.timeline); + if let Some(timeline) = timelines.get(timeline_name) { + self.timeline = ActiveTimeline::UserEdited(*timeline); } else { self.timeline = ActiveTimeline::Pending(Timeline::new_sequence(*timeline_name)); } - if let Some(state) = self.states.get(timeline_name) { - // Use the new timeline's cached time selection. - if let Some(blueprint_ctx) = blueprint_ctx { - match state.loop_selection { - Some(selection) => blueprint_ctx.set_time_selection(selection.to_int()), - None => blueprint_ctx.clear_time_selection(), - } - } - } else if let Some(full_valid_range) = self.full_valid_range(times_per_timeline) { + if let Some(full_valid_range) = self.full_valid_range(time_histogram_per_timeline) + && !self.states.contains_key(timeline_name) + { self.states .insert(*timeline_name, TimeState::new(full_valid_range.min)); + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(full_valid_range.min); + } } NeedsRepaint::Yes @@ -851,7 +991,7 @@ impl TimeControl { if self.loop_mode != LoopMode::Off { if self.play_state() == PlayState::Following { self.set_play_state( - Some(times_per_timeline), + time_histogram_per_timeline, PlayState::Playing, blueprint_ctx, ); @@ -867,20 +1007,16 @@ impl TimeControl { } } TimeControlCommand::SetPlayState(play_state) => { - if self.play_state() != *play_state { - self.set_play_state(Some(times_per_timeline), *play_state, blueprint_ctx); + self.set_play_state(time_histogram_per_timeline, *play_state, blueprint_ctx); - if self.following { - if let Some(blueprint_ctx) = blueprint_ctx { - blueprint_ctx.set_loop_mode(LoopMode::Off); - } - self.loop_mode = LoopMode::Off; + if self.following { + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_loop_mode(LoopMode::Off); } - - NeedsRepaint::Yes - } else { - NeedsRepaint::No + self.loop_mode = LoopMode::Off; } + + NeedsRepaint::Yes } TimeControlCommand::Pause => { if self.playing { @@ -892,24 +1028,28 @@ impl TimeControl { } TimeControlCommand::TogglePlayPause => { - self.toggle_play_pause(times_per_timeline, blueprint_ctx); + self.toggle_play_pause(time_histogram_per_timeline, timelines, blueprint_ctx); NeedsRepaint::Yes } TimeControlCommand::StepTimeBack => { - self.step_time_back(times_per_timeline, blueprint_ctx); + self.step_time_back(time_histogram_per_timeline, blueprint_ctx); NeedsRepaint::Yes } TimeControlCommand::StepTimeForward => { - self.step_time_fwd(times_per_timeline, blueprint_ctx); + self.step_time_fwd(time_histogram_per_timeline, blueprint_ctx); NeedsRepaint::Yes } TimeControlCommand::Restart => { - if let Some(full_valid_range) = self.full_valid_range(times_per_timeline) { + if let Some(full_valid_range) = self.full_valid_range(time_histogram_per_timeline) { self.following = false; + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(full_valid_range.min); + } + if let Some(state) = self.states.get_mut(self.timeline.name()) { state.time = full_valid_range.min.into(); } @@ -966,13 +1106,6 @@ impl TimeControl { blueprint_ctx.clear_time_selection(); } state.loop_selection = None; - if self.loop_mode == LoopMode::Selection { - self.loop_mode = LoopMode::Off; - - if let Some(blueprint_ctx) = blueprint_ctx { - blueprint_ctx.set_loop_mode(self.loop_mode); - } - } NeedsRepaint::Yes } else { @@ -981,13 +1114,18 @@ impl TimeControl { } TimeControlCommand::SetTime(time) => { let time_int = time.floor(); - let repaint = self.time_int() != Some(time_int); + let update_blueprint = self.time_int() != Some(time_int); + if let Some(blueprint_ctx) = blueprint_ctx + && update_blueprint + { + blueprint_ctx.set_time(time_int); + } self.states .entry(*self.timeline.name()) .or_insert_with(|| TimeState::new(*time)) .time = *time; - if repaint { + if update_blueprint { NeedsRepaint::Yes } else { NeedsRepaint::No @@ -1027,7 +1165,7 @@ impl TimeControl { /// blueprint. pub fn set_play_state( &mut self, - times_per_timeline: Option<&TimesPerTimeline>, + time_histogram_per_timeline: &TimeHistogramPerTimeline, play_state: PlayState, blueprint_ctx: Option<&impl BlueprintContext>, ) { @@ -1040,22 +1178,34 @@ impl TimeControl { match play_state { PlayState::Paused => { self.playing = false; + if let Some(state) = self.states.get_mut(self.timeline.name()) { + state.last_paused_time = Some(state.time); + // Persist the time cursor to blueprint when pausing, so other clients + // and blueprint undo/redo see where we paused. + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(state.time.floor()); + } + } } PlayState::Playing => { self.playing = true; self.following = false; // Start from beginning if we are at the end: - if let Some(times_per_timeline) = times_per_timeline - && let Some(timeline_stats) = times_per_timeline.get(self.timeline.name()) - { + if let Some(hist) = time_histogram_per_timeline.get(self.timeline.name()) { if let Some(state) = self.states.get_mut(self.timeline.name()) { - if max(&timeline_stats.per_time) <= state.time { - let new_time = min(&timeline_stats.per_time); + if max(hist) <= state.time { + let new_time = min(hist); + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(new_time); + } state.time = new_time.into(); } } else { - let new_time = min(&timeline_stats.per_time); + let new_time = min(hist); + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(new_time); + } self.states .insert(*self.timeline.name(), TimeState::new(new_time)); } @@ -1065,11 +1215,12 @@ impl TimeControl { self.playing = true; self.following = true; - if let Some(times_per_timeline) = times_per_timeline - && let Some(timeline_stats) = times_per_timeline.get(self.timeline.name()) - { + if let Some(hist) = time_histogram_per_timeline.get(self.timeline.name()) { // Set the time to the max: - let new_time = max(&timeline_stats.per_time); + let new_time = max(hist); + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(new_time); + } self.states .entry(*self.timeline.name()) .or_insert_with(|| TimeState::new(new_time)) @@ -1081,10 +1232,10 @@ impl TimeControl { fn step_time_back( &mut self, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, blueprint_ctx: Option<&impl BlueprintContext>, ) { - let Some(timeline_stats) = times_per_timeline.get(self.timeline().name()) else { + let Some(hist) = time_histogram_per_timeline.get(self.timeline().name()) else { return; }; @@ -1092,10 +1243,13 @@ impl TimeControl { if let Some(time) = self.time() { let new_time = if let Some(loop_range) = self.active_loop_selection() { - step_back_time_looped(time, &timeline_stats.per_time, &loop_range) + step_back_time_looped(time, hist, &loop_range) } else { - step_back_time(time, &timeline_stats.per_time).into() + step_back_time(time, hist).into() }; + if let Some(ctx) = blueprint_ctx { + ctx.set_time(new_time.floor()); + } if let Some(state) = self.states.get_mut(self.timeline.name()) { state.time = new_time; @@ -1105,10 +1259,10 @@ impl TimeControl { fn step_time_fwd( &mut self, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, blueprint_ctx: Option<&impl BlueprintContext>, ) { - let Some(stats) = times_per_timeline.get(self.timeline().name()) else { + let Some(hist) = time_histogram_per_timeline.get(self.timeline().name()) else { return; }; @@ -1116,10 +1270,13 @@ impl TimeControl { if let Some(time) = self.time() { let new_time = if let Some(loop_range) = self.active_loop_selection() { - step_fwd_time_looped(time, &stats.per_time, &loop_range) + step_fwd_time_looped(time, hist, &loop_range) } else { - step_fwd_time(time, &stats.per_time).into() + step_fwd_time(time, hist).into() }; + if let Some(ctx) = blueprint_ctx { + ctx.set_time(new_time.floor()); + } if let Some(state) = self.states.get_mut(self.timeline.name()) { state.time = new_time; @@ -1134,12 +1291,18 @@ impl TimeControl { } if let Some(state) = self.states.get_mut(self.timeline.name()) { state.last_paused_time = Some(state.time); + // Persist the time cursor to blueprint when pausing, so other clients + // and blueprint undo/redo see where we paused. + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(state.time.floor()); + } } } fn toggle_play_pause( &mut self, - times_per_timeline: &TimesPerTimeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + _timelines: &std::collections::BTreeMap, blueprint_ctx: Option<&impl BlueprintContext>, ) { if self.playing { @@ -1168,11 +1331,14 @@ impl TimeControl { // the beginning in play mode. // Start from beginning if we are at the end: - if let Some(stats) = times_per_timeline.get(self.timeline.name()) + if let Some(hist) = time_histogram_per_timeline.get(self.timeline.name()) && let Some(state) = self.states.get_mut(self.timeline.name()) - && max(&stats.per_time) <= state.time + && max(hist) <= state.time { - let new_time = min(&stats.per_time); + let new_time = min(hist); + if let Some(blueprint_ctx) = blueprint_ctx { + blueprint_ctx.set_time(new_time); + } state.time = new_time.into(); self.playing = true; self.following = false; @@ -1180,13 +1346,9 @@ impl TimeControl { } if self.following { - self.set_play_state( - Some(times_per_timeline), - PlayState::Following, - blueprint_ctx, - ); + self.set_play_state(time_histogram_per_timeline, PlayState::Following, blueprint_ctx); } else { - self.set_play_state(Some(times_per_timeline), PlayState::Playing, blueprint_ctx); + self.set_play_state(time_histogram_per_timeline, PlayState::Playing, blueprint_ctx); } } } @@ -1204,14 +1366,16 @@ impl TimeControl { } /// Make sure the selected timeline is a valid one - fn select_valid_timeline(&mut self, times_per_timeline: &TimesPerTimeline) { - fn is_timeline_valid(selected: &Timeline, times_per_timeline: &TimesPerTimeline) -> bool { - for timeline in times_per_timeline.timelines() { - if selected == timeline { - return true; // it's valid - } - } - false + fn select_valid_timeline( + &mut self, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, + ) { + fn is_timeline_valid( + selected: &Timeline, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + ) -> bool { + time_histogram_per_timeline.has_timeline(selected.name()) } let reset_timeline = match &self.timeline { @@ -1219,15 +1383,12 @@ impl TimeControl { ActiveTimeline::Auto(_) => true, // If it's user edited, refresh it if it's invalid. ActiveTimeline::UserEdited(timeline) => { - !is_timeline_valid(timeline, times_per_timeline) + !is_timeline_valid(timeline, time_histogram_per_timeline) } // If it's pending never automatically refresh it. ActiveTimeline::Pending(timeline) => { // If the pending timeline is valid, it shouldn't be pending anymore. - if let Some(timeline) = times_per_timeline - .timelines() - .find(|t| t.name() == timeline.name()) - { + if let Some(timeline) = timelines.get(timeline.name()) { self.timeline = ActiveTimeline::UserEdited(*timeline); } @@ -1237,7 +1398,7 @@ impl TimeControl { if reset_timeline || matches!(self.timeline, ActiveTimeline::Auto(_)) { self.timeline = - ActiveTimeline::Auto(default_timeline(times_per_timeline.timelines_with_stats())); + ActiveTimeline::Auto(default_timeline(time_histogram_per_timeline, timelines)); } } @@ -1378,9 +1539,12 @@ impl TimeControl { /// The full range of times for the current timeline, skipping times outside of the valid data ranges /// at the start and end. - fn full_valid_range(&self, times_per_timeline: &TimesPerTimeline) -> Option { - times_per_timeline.get(self.timeline().name()).map(|stats| { - let data_range = range(&stats.per_time); + fn full_valid_range( + &self, + time_histogram_per_timeline: &TimeHistogramPerTimeline, + ) -> Option { + time_histogram_per_timeline.get(self.timeline().name()).map(|hist| { + let data_range = range(hist); let max_valid_range_for = self.max_valid_range_for(*self.timeline().name()); AbsoluteTimeRange::new( data_range.min.max(max_valid_range_for.min), @@ -1426,20 +1590,27 @@ impl TimeControl { } } -fn min(values: &TimeCounts) -> TimeInt { - *values.keys().next().unwrap_or(&TimeInt::MIN) +fn min(hist: &TimeHistogram) -> TimeInt { + hist.min_key() + .map(TimeInt::new_temporal) + .unwrap_or(TimeInt::MIN) } -fn max(values: &TimeCounts) -> TimeInt { - *values.keys().next_back().unwrap_or(&TimeInt::MIN) +fn max(hist: &TimeHistogram) -> TimeInt { + hist.max_key() + .map(TimeInt::new_temporal) + .unwrap_or(TimeInt::MIN) } -fn range(values: &TimeCounts) -> AbsoluteTimeRange { - AbsoluteTimeRange::new(min(values), max(values)) +fn range(hist: &TimeHistogram) -> AbsoluteTimeRange { + AbsoluteTimeRange::new(min(hist), max(hist)) } /// Pick the timeline that should be the default, by number of elements and prioritizing user-defined ones. -fn default_timeline<'a>(timelines: impl IntoIterator) -> Timeline { +fn default_timeline( + time_histogram_per_timeline: &TimeHistogramPerTimeline, + timelines: &std::collections::BTreeMap, +) -> Timeline { re_tracing::profile_function!(); // Helper function that acts as a tie-breaker. @@ -1450,73 +1621,88 @@ fn default_timeline<'a>(timelines: impl IntoIterator) _ => 2, // user-defined, highest priority } } - let most_events = timelines.into_iter().max_by(|a, b| { - a.num_events() - .cmp(&b.num_events()) - .then_with(|| timeline_priority(&a.timeline).cmp(&timeline_priority(&b.timeline))) - }); - - if let Some(most_events) = most_events { - most_events.timeline + let most_events = time_histogram_per_timeline + .iter() + .filter_map(|(name, hist)| { + timelines.get(name).map(|timeline| { + (timeline, hist.total_count()) + }) + }) + .max_by(|(a_timeline, a_count), (b_timeline, b_count)| { + a_count + .cmp(b_count) + .then_with(|| { + timeline_priority(a_timeline).cmp(&timeline_priority(b_timeline)) + }) + }); + + if let Some((timeline, _)) = most_events { + *timeline } else { Timeline::log_time() } } -fn step_fwd_time(time: TimeReal, values: &TimeCounts) -> TimeInt { - if let Some((next, _)) = values - .range(( - std::ops::Bound::Excluded(time.floor()), - std::ops::Bound::Unbounded, - )) - .next() - { - *next - } else { - min(values) - } +fn step_fwd_time(time: TimeReal, hist: &TimeHistogram) -> TimeInt { + hist.next_key_after(time.floor().as_i64()) + .map(TimeInt::new_temporal) + .unwrap_or_else(|| min(hist)) } -fn step_back_time(time: TimeReal, values: &TimeCounts) -> TimeInt { - if let Some((previous, _)) = values.range(..time.ceil()).next_back() { - *previous - } else { - max(values) - } +fn step_back_time(time: TimeReal, hist: &TimeHistogram) -> TimeInt { + hist.prev_key_before(time.ceil().as_i64()) + .map(TimeInt::new_temporal) + .unwrap_or_else(|| max(hist)) } fn step_fwd_time_looped( time: TimeReal, - values: &TimeCounts, + hist: &TimeHistogram, loop_range: &AbsoluteTimeRangeF, ) -> TimeReal { if time < loop_range.min || loop_range.max <= time { loop_range.min - } else if let Some((next, _)) = values - .range(( - std::ops::Bound::Excluded(time.floor()), - std::ops::Bound::Included(loop_range.max.floor()), - )) + } else if let Some(next) = hist + .range( + ( + std::ops::Bound::Excluded(time.floor().as_i64()), + std::ops::Bound::Included(loop_range.max.floor().as_i64()), + ), + 1, + ) .next() + .map(|(r, _)| r.min) { - TimeReal::from(*next) + TimeReal::from(TimeInt::new_temporal(next)) } else { - step_fwd_time(time, values).into() + step_fwd_time(time, hist).into() } } fn step_back_time_looped( time: TimeReal, - values: &TimeCounts, + hist: &TimeHistogram, loop_range: &AbsoluteTimeRangeF, ) -> TimeReal { if time <= loop_range.min || loop_range.max < time { loop_range.max - } else if let Some((previous, _)) = values.range(loop_range.min.ceil()..time.ceil()).next_back() - { - TimeReal::from(*previous) } else { - step_back_time(time, values).into() + // Collect all keys in the range and take the last one + let mut prev_key = None; + for (range, _) in hist.range( + ( + std::ops::Bound::Included(loop_range.min.ceil().as_i64()), + std::ops::Bound::Excluded(time.ceil().as_i64()), + ), + 1, + ) { + prev_key = Some(range.max); + } + if let Some(prev) = prev_key { + TimeReal::from(TimeInt::new_temporal(prev)) + } else { + step_back_time(time, hist).into() + } } } @@ -1524,63 +1710,161 @@ fn step_back_time_looped( mod tests { use super::*; - fn with_events(timeline: Timeline, num: u64) -> TimelineStats { - TimelineStats { - timeline, - // Dummy `TimeInt` because were only interested in the counts. - per_time: std::iter::once((TimeInt::ZERO, num)).collect(), - total_count: num, + fn create_test_data(timelines: &[Timeline], counts: &[u64]) -> (TimeHistogramPerTimeline, std::collections::BTreeMap) { + let mut hist_per_timeline = TimeHistogramPerTimeline::default(); + let mut timeline_map = std::collections::BTreeMap::new(); + + for (timeline, &count) in timelines.iter().zip(counts.iter()) { + timeline_map.insert(*timeline.name(), *timeline); + // Add count events at time 0 to simulate the count + // Use the add method which takes (TimelineName, &[i64]) pairs + let times: Vec = vec![0; count as usize]; + hist_per_timeline.add(&[(*timeline.name(), ×)], 1); } + + (hist_per_timeline, timeline_map) } #[test] fn test_default_timeline() { - let log_time = with_events(Timeline::log_time(), 42); - let log_tick = with_events(Timeline::log_tick(), 42); - let custom_timeline0 = with_events(Timeline::new("my_timeline0", TimeType::DurationNs), 42); - let custom_timeline1 = with_events(Timeline::new("my_timeline1", TimeType::DurationNs), 43); - - assert_eq!(default_timeline([]), log_time.timeline); - assert_eq!(default_timeline([&log_tick]), log_tick.timeline); - assert_eq!(default_timeline([&log_time]), log_time.timeline); - assert_eq!(default_timeline([&log_time, &log_tick]), log_time.timeline); - assert_eq!( - default_timeline([&log_time, &log_tick, &custom_timeline0]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&custom_timeline0, &log_time, &log_tick]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&log_time, &custom_timeline0, &log_tick]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&custom_timeline0, &log_time]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&custom_timeline0, &log_tick]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&log_time, &custom_timeline0]), - custom_timeline0.timeline - ); - assert_eq!( - default_timeline([&log_tick, &custom_timeline0]), - custom_timeline0.timeline - ); + let log_time = Timeline::log_time(); + let log_tick = Timeline::log_tick(); + let custom_timeline0 = Timeline::new("my_timeline0", TimeType::DurationNs); + let custom_timeline1 = Timeline::new("my_timeline1", TimeType::DurationNs); - assert_eq!( - default_timeline([&custom_timeline0, &custom_timeline1]), - custom_timeline1.timeline - ); - assert_eq!( - default_timeline([&custom_timeline0]), - custom_timeline0.timeline - ); + // Empty case + let (empty_hist, empty_timelines) = create_test_data(&[], &[]); + assert_eq!(default_timeline(&empty_hist, &empty_timelines), log_time); + + // Single timeline cases + let (hist_tick, timelines_tick) = create_test_data(&[log_tick], &[42]); + assert_eq!(default_timeline(&hist_tick, &timelines_tick), log_tick); + + let (hist_time, timelines_time) = create_test_data(&[log_time], &[42]); + assert_eq!(default_timeline(&hist_time, &timelines_time), log_time); + + // Multiple timelines - log_time should win over log_tick when counts are equal + let (hist_both, timelines_both) = create_test_data(&[log_time, log_tick], &[42, 42]); + assert_eq!(default_timeline(&hist_both, &timelines_both), log_time); + + // Custom timeline should win over both log_time and log_tick when counts are equal + let (hist_custom0, timelines_custom0) = create_test_data(&[log_time, log_tick, custom_timeline0], &[42, 42, 42]); + assert_eq!(default_timeline(&hist_custom0, &timelines_custom0), custom_timeline0); + + // Order shouldn't matter + let (hist_custom0_rev, timelines_custom0_rev) = create_test_data(&[custom_timeline0, log_time, log_tick], &[42, 42, 42]); + assert_eq!(default_timeline(&hist_custom0_rev, &timelines_custom0_rev), custom_timeline0); + + let (hist_custom0_mid, timelines_custom0_mid) = create_test_data(&[log_time, custom_timeline0, log_tick], &[42, 42, 42]); + assert_eq!(default_timeline(&hist_custom0_mid, &timelines_custom0_mid), custom_timeline0); + + // Custom timelines with different counts - higher count wins + let (hist_custom1, timelines_custom1) = create_test_data(&[custom_timeline0, custom_timeline1], &[42, 43]); + assert_eq!(default_timeline(&hist_custom1, &timelines_custom1), custom_timeline1); + + // Single custom timeline + let (hist_single_custom, timelines_single_custom) = create_test_data(&[custom_timeline0], &[42]); + assert_eq!(default_timeline(&hist_single_custom, &timelines_single_custom), custom_timeline0); + } + + #[test] + fn test_step_fwd_time() { + use re_log_types::TimeReal; + + let mut hist = TimeHistogram::default(); + hist.increment(10, 1); + hist.increment(20, 1); + hist.increment(30, 1); + + // Step forward from before first key + assert_eq!(step_fwd_time(TimeReal::from(5), &hist), TimeInt::new_temporal(10)); + + // Step forward from middle + assert_eq!(step_fwd_time(TimeReal::from(15), &hist), TimeInt::new_temporal(20)); + + // Step forward from last key (wraps around) + assert_eq!(step_fwd_time(TimeReal::from(30), &hist), TimeInt::new_temporal(10)); + + // Step forward from after last key (wraps around) + assert_eq!(step_fwd_time(TimeReal::from(35), &hist), TimeInt::new_temporal(10)); + + // Empty histogram + let empty_hist = TimeHistogram::default(); + assert_eq!(step_fwd_time(TimeReal::from(10), &empty_hist), TimeInt::MIN); + } + + #[test] + fn test_step_back_time() { + use re_log_types::TimeReal; + + let mut hist = TimeHistogram::default(); + hist.increment(10, 1); + hist.increment(20, 1); + hist.increment(30, 1); + + // Step back from after last key + assert_eq!(step_back_time(TimeReal::from(35), &hist), TimeInt::new_temporal(30)); + + // Step back from middle + assert_eq!(step_back_time(TimeReal::from(25), &hist), TimeInt::new_temporal(20)); + + // Step back from first key (wraps around) + assert_eq!(step_back_time(TimeReal::from(10), &hist), TimeInt::new_temporal(30)); + + // Step back from before first key (wraps around) + assert_eq!(step_back_time(TimeReal::from(5), &hist), TimeInt::new_temporal(30)); + + // Empty histogram + let empty_hist = TimeHistogram::default(); + assert_eq!(step_back_time(TimeReal::from(10), &empty_hist), TimeInt::MIN); + } + + #[test] + fn test_step_fwd_time_looped() { + use re_log_types::{AbsoluteTimeRangeF, TimeReal}; + + let mut hist = TimeHistogram::default(); + hist.increment(10, 1); + hist.increment(20, 1); + hist.increment(30, 1); + + let loop_range = AbsoluteTimeRangeF::new(15.0, 25.0); + + // Before loop range - should jump to start + assert_eq!(step_fwd_time_looped(TimeReal::from(5), &hist, &loop_range), TimeReal::from(15.0)); + + // In loop range - should step to next key + assert_eq!(step_fwd_time_looped(TimeReal::from(15), &hist, &loop_range), TimeReal::from(20)); + + // At end of loop range - should wrap to start + assert_eq!(step_fwd_time_looped(TimeReal::from(25), &hist, &loop_range), TimeReal::from(15.0)); + + // After loop range - should jump to start + assert_eq!(step_fwd_time_looped(TimeReal::from(35), &hist, &loop_range), TimeReal::from(15.0)); + } + + #[test] + fn test_step_back_time_looped() { + use re_log_types::{AbsoluteTimeRangeF, TimeReal}; + + let mut hist = TimeHistogram::default(); + hist.increment(10, 1); + hist.increment(20, 1); + hist.increment(30, 1); + + let loop_range = AbsoluteTimeRangeF::new(15.0, 25.0); + + // Before loop range - should jump to end + assert_eq!(step_back_time_looped(TimeReal::from(5), &hist, &loop_range), TimeReal::from(25.0)); + + // In loop range - should step to previous key + assert_eq!(step_back_time_looped(TimeReal::from(25), &hist, &loop_range), TimeReal::from(20)); + + // At start of loop range - should wrap to end + assert_eq!(step_back_time_looped(TimeReal::from(15), &hist, &loop_range), TimeReal::from(25.0)); + + // After loop range - should jump to end + assert_eq!(step_back_time_looped(TimeReal::from(35), &hist, &loop_range), TimeReal::from(25.0)); } #[test] diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.cpp b/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.cpp index 8aa423ad1029..5c70fc7e0cf3 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.cpp +++ b/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.cpp @@ -14,6 +14,9 @@ namespace rerun::blueprint::archetypes { archetype.timeline = ComponentBatch::empty(Descriptor_timeline) .value_or_throw(); + archetype.time = + ComponentBatch::empty(Descriptor_time) + .value_or_throw(); archetype.playback_speed = ComponentBatch::empty( Descriptor_playback_speed @@ -37,13 +40,16 @@ namespace rerun::blueprint::archetypes { Collection TimePanelBlueprint::columns(const Collection& lengths_) { std::vector columns; - columns.reserve(7); + columns.reserve(8); if (state.has_value()) { columns.push_back(state.value().partitioned(lengths_).value_or_throw()); } if (timeline.has_value()) { columns.push_back(timeline.value().partitioned(lengths_).value_or_throw()); } + if (time.has_value()) { + columns.push_back(time.value().partitioned(lengths_).value_or_throw()); + } if (playback_speed.has_value()) { columns.push_back(playback_speed.value().partitioned(lengths_).value_or_throw()); } @@ -69,6 +75,9 @@ namespace rerun::blueprint::archetypes { if (timeline.has_value()) { return columns(std::vector(timeline.value().length(), 1)); } + if (time.has_value()) { + return columns(std::vector(time.value().length(), 1)); + } if (playback_speed.has_value()) { return columns(std::vector(playback_speed.value().length(), 1)); } @@ -96,7 +105,7 @@ namespace rerun { ) { using namespace blueprint::archetypes; std::vector cells; - cells.reserve(7); + cells.reserve(8); if (archetype.state.has_value()) { cells.push_back(archetype.state.value()); @@ -104,6 +113,9 @@ namespace rerun { if (archetype.timeline.has_value()) { cells.push_back(archetype.timeline.value()); } + if (archetype.time.has_value()) { + cells.push_back(archetype.time.value()); + } if (archetype.playback_speed.has_value()) { cells.push_back(archetype.playback_speed.value()); } diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.hpp b/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.hpp index a2ef41e854dc..a8644b6e0d7a 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.hpp +++ b/rerun_cpp/src/rerun/blueprint/archetypes/time_panel_blueprint.hpp @@ -9,6 +9,7 @@ #include "../../blueprint/components/panel_state.hpp" #include "../../blueprint/components/play_state.hpp" #include "../../blueprint/components/playback_speed.hpp" +#include "../../blueprint/components/time_int.hpp" #include "../../blueprint/components/timeline_name.hpp" #include "../../collection.hpp" #include "../../component_batch.hpp" @@ -32,6 +33,9 @@ namespace rerun::blueprint::archetypes { /// What timeline the panel is on. std::optional timeline; + /// What time the time cursor should be on. + std::optional time; + /// A time playback speed multiplier. std::optional playback_speed; @@ -66,6 +70,11 @@ namespace rerun::blueprint::archetypes { ArchetypeName, "TimePanelBlueprint:timeline", Loggable::ComponentType ); + /// `ComponentDescriptor` for the `time` field. + static constexpr auto Descriptor_time = ComponentDescriptor( + ArchetypeName, "TimePanelBlueprint:time", + Loggable::ComponentType + ); /// `ComponentDescriptor` for the `playback_speed` field. static constexpr auto Descriptor_playback_speed = ComponentDescriptor( ArchetypeName, "TimePanelBlueprint:playback_speed", @@ -121,6 +130,12 @@ namespace rerun::blueprint::archetypes { return std::move(*this); } + /// What time the time cursor should be on. + TimePanelBlueprint with_time(const rerun::blueprint::components::TimeInt& _time) && { + time = ComponentBatch::from_loggable(_time, Descriptor_time).value_or_throw(); + return std::move(*this); + } + /// A time playback speed multiplier. TimePanelBlueprint with_playback_speed( const rerun::blueprint::components::PlaybackSpeed& _playback_speed diff --git a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/time_panel_blueprint.py b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/time_panel_blueprint.py index fd77de04e2d1..31934253fc48 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/time_panel_blueprint.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/time_panel_blueprint.py @@ -34,6 +34,7 @@ def __init__( *, state: blueprint_components.PanelStateLike | None = None, timeline: datatypes.Utf8Like | None = None, + time: datatypes.TimeIntLike | None = None, playback_speed: datatypes.Float64Like | None = None, fps: datatypes.Float64Like | None = None, play_state: blueprint_components.PlayStateLike | None = None, @@ -49,6 +50,8 @@ def __init__( Current state of the panel. timeline: What timeline the panel is on. + time: + What time the time cursor should be on. playback_speed: A time playback speed multiplier. fps: @@ -71,6 +74,7 @@ def __init__( self.__attrs_init__( state=state, timeline=timeline, + time=time, playback_speed=playback_speed, fps=fps, play_state=play_state, @@ -85,6 +89,7 @@ def __attrs_clear__(self) -> None: self.__attrs_init__( state=None, timeline=None, + time=None, playback_speed=None, fps=None, play_state=None, @@ -106,6 +111,7 @@ def from_fields( clear_unset: bool = False, state: blueprint_components.PanelStateLike | None = None, timeline: datatypes.Utf8Like | None = None, + time: datatypes.TimeIntLike | None = None, playback_speed: datatypes.Float64Like | None = None, fps: datatypes.Float64Like | None = None, play_state: blueprint_components.PlayStateLike | None = None, @@ -123,6 +129,8 @@ def from_fields( Current state of the panel. timeline: What timeline the panel is on. + time: + What time the time cursor should be on. playback_speed: A time playback speed multiplier. fps: @@ -145,6 +153,7 @@ def from_fields( kwargs = { "state": state, "timeline": timeline, + "time": time, "playback_speed": playback_speed, "fps": fps, "play_state": play_state, @@ -184,6 +193,15 @@ def cleared(cls) -> TimePanelBlueprint: # # (Docstring intentionally commented out to hide this field from the docs) + time: blueprint_components.TimeIntBatch | None = field( + metadata={"component": True}, + default=None, + converter=blueprint_components.TimeIntBatch._converter, # type: ignore[misc] + ) + # What time the time cursor should be on. + # + # (Docstring intentionally commented out to hide this field from the docs) + playback_speed: blueprint_components.PlaybackSpeedBatch | None = field( metadata={"component": True}, default=None, From a54cf873009edd5945d8a2ac5e9e341d9086e6ba Mon Sep 17 00:00:00 2001 From: Joel Reymont <18791+joelreymont@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:04:45 +0200 Subject: [PATCH 2/2] Fix TimeHistogramPerTimeline to count chunks not components --- crates/store/re_entity_db/src/time_histogram_per_timeline.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/store/re_entity_db/src/time_histogram_per_timeline.rs b/crates/store/re_entity_db/src/time_histogram_per_timeline.rs index 36a7596b4460..4bf02962bf4d 100644 --- a/crates/store/re_entity_db/src/time_histogram_per_timeline.rs +++ b/crates/store/re_entity_db/src/time_histogram_per_timeline.rs @@ -149,10 +149,10 @@ impl ChunkStoreSubscriber for TimeHistogramPerTimeline { .collect_vec(); match event.kind { ChunkStoreDiffKind::Addition => { - self.add(×, event.num_components() as _); + self.add(×, 1); } ChunkStoreDiffKind::Deletion => { - self.remove(×, event.num_components() as _); + self.remove(×, 1); } } }