diff --git a/Cargo.lock b/Cargo.lock index 2e1486d922a7..abe3f0d4974b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10095,6 +10095,7 @@ dependencies = [ "re_selection_panel", "re_smart_channel", "re_test_context", + "re_test_viewport", "re_time_panel", "re_tracing", "re_types", @@ -10122,6 +10123,7 @@ dependencies = [ "strum", "strum_macros", "tap", + "tempfile", "thiserror 1.0.69", "tokio", "url", diff --git a/crates/viewer/re_viewer/Cargo.toml b/crates/viewer/re_viewer/Cargo.toml index ca9f322d5618..42028d3a473d 100644 --- a/crates/viewer/re_viewer/Cargo.toml +++ b/crates/viewer/re_viewer/Cargo.toml @@ -176,8 +176,9 @@ web-sys = { workspace = true, features = [ [dev-dependencies] egui_kittest.workspace = true re_test_context.workspace = true +re_test_viewport.workspace = true +tempfile.workspace = true tokio.workspace = true - [build-dependencies] re_build_tools.workspace = true diff --git a/crates/viewer/re_viewer/tests/blueprint_test.rs b/crates/viewer/re_viewer/tests/blueprint_test.rs new file mode 100644 index 000000000000..efc6dc3b4c11 --- /dev/null +++ b/crates/viewer/re_viewer/tests/blueprint_test.rs @@ -0,0 +1,141 @@ +// Tests for saving/loading blueprints to/from a file. +use std::path::Path; + +use re_chunk::{RowId, TimePoint}; +use re_test_context::TestContext; +use re_test_viewport::TestContextExt as _; +use re_viewer_context::ViewClass as _; +use re_viewport::ViewportUi; +use re_viewport_blueprint::ViewBlueprint; + +fn log_test_data_and_register_views(test_context: &mut TestContext, scalars_count: usize) { + test_context.register_view_class::(); + test_context.register_view_class::(); + + let timeline_a = re_chunk::Timeline::new_sequence("timeline_a"); + test_context.log_entity("scalar", |builder| { + builder.with_archetype( + RowId::new(), + [(timeline_a, 0)], + &re_types::archetypes::Scalars::single(scalars_count as f32), + ) + }); + + let vector = (0..scalars_count).map(|i| i as f32).collect::>(); + + test_context.log_entity("vector", |builder| { + builder.with_archetype( + RowId::new(), + TimePoint::STATIC, + &re_types::archetypes::BarChart::new(vector), + ) + }); +} + +fn setup_viewport(test_context: &mut TestContext) { + let view_1 = + ViewBlueprint::new_with_root_wildcard(re_view_bar_chart::BarChartView::identifier()); + let view_2 = + ViewBlueprint::new_with_root_wildcard(re_view_dataframe::DataframeView::identifier()); + + test_context.setup_viewport_blueprint(|ctx, blueprint| { + // Set the color override for the bar chart view. + let color_override = re_types::archetypes::BarChart::default().with_color([255, 144, 1]); // #FF9001 + let override_path = re_viewport_blueprint::ViewContents::override_path_for_entity( + view_1.id, + &re_chunk::EntityPath::from("vector"), + ); + ctx.save_blueprint_archetype(override_path.clone(), &color_override); + + // Set the timeline for the dataframe view. + let query = re_view_dataframe::Query::from_blueprint(ctx, view_2.id); + query.save_timeline_name(ctx, &re_chunk::TimelineName::from("timeline_a")); + + blueprint.add_views([view_1, view_2].into_iter(), None, None); + }); +} + +fn save_blueprint_to_file(test_context: &TestContext, path: &Path) { + test_context + .save_blueprint_to_file(path) + .expect("Failed to save blueprint to file."); +} + +fn load_blueprint_from_file(test_context: &mut TestContext, path: &Path) { + let file = std::fs::File::open(path).expect("Failed to open blueprint file."); + let rbl_store = + re_entity_db::StoreBundle::from_rrd(file).expect("Failed to load blueprint store"); + { + let mut lock = test_context.store_hub.lock(); + let app_id = lock.active_app().expect("Missing active app").clone(); + lock.load_blueprint_store(rbl_store, &app_id) + .expect("Failed to load blueprint store"); + } + + // Trigger recalculation of visualizable entities and blueprint overrides. + test_context.setup_viewport_blueprint(|_ctx, _blueprint| {}); +} + +fn take_snapshot(test_context: &mut TestContext, snapshot_name: &str) { + let mut harness = test_context + .setup_kittest_for_rendering() + .with_size(egui::vec2(600.0, 400.0)) + .build_ui(|ui| { + test_context.run_ui(ui, |ctx, ui| { + let viewport_blueprint = re_viewport_blueprint::ViewportBlueprint::from_db( + ctx.blueprint_db(), + &test_context.blueprint_query, + ); + let viewport_ui = ViewportUi::new(viewport_blueprint); + viewport_ui.viewport_ui(ui, ctx, &mut test_context.view_states.lock()); + }); + + test_context.handle_system_commands(); + }); + harness.run(); + harness.snapshot(snapshot_name); +} + +#[test] +fn test_blueprint_change_and_restore() { + let mut test_context = TestContext::new(); + log_test_data_and_register_views(&mut test_context, 16); + let rbl_file = tempfile::NamedTempFile::new().unwrap(); + let rbl_path = rbl_file.path(); + + setup_viewport(&mut test_context); + save_blueprint_to_file(&test_context, rbl_path); + + // Remove the first view and add 3 new ones. + test_context.setup_viewport_blueprint(|_ctx, blueprint| { + let first_view_id = *blueprint.view_ids().next().unwrap(); + blueprint.remove_contents(re_viewer_context::Contents::View(first_view_id)); + blueprint.add_views([ + ViewBlueprint::new_with_root_wildcard(re_view_bar_chart::BarChartView::identifier()), + ViewBlueprint::new_with_root_wildcard(re_view_bar_chart::BarChartView::identifier()), + ViewBlueprint::new_with_root_wildcard(re_view_bar_chart::BarChartView::identifier()), + ].into_iter(), None, None); + }); + + load_blueprint_from_file(&mut test_context, rbl_path); + take_snapshot(&mut test_context, "blueprint_change_and_restore"); +} + +#[test] +fn test_blueprint_load_into_new_context() { + let mut test_context = TestContext::new(); + log_test_data_and_register_views(&mut test_context, 10); + + let rbl_file = tempfile::NamedTempFile::new().unwrap(); + let rbl_path = rbl_file.path(); + + setup_viewport(&mut test_context); + save_blueprint_to_file(&test_context, rbl_path); + take_snapshot(&mut test_context, "blueprint_load_into_new_context_1"); + + let mut test_context_2 = TestContext::new(); + log_test_data_and_register_views(&mut test_context_2, 20); + + load_blueprint_from_file(&mut test_context_2, rbl_path); + take_snapshot(&mut test_context_2, "blueprint_load_into_new_context_2"); +} diff --git a/crates/viewer/re_viewer/tests/snapshots/blueprint_change_and_restore.png b/crates/viewer/re_viewer/tests/snapshots/blueprint_change_and_restore.png new file mode 100644 index 000000000000..3db4af1167ee --- /dev/null +++ b/crates/viewer/re_viewer/tests/snapshots/blueprint_change_and_restore.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1dc9de96e25110ef746daa90f0609b48d9f8bacd5e0a93263fd60c1865f370ee +size 30108 diff --git a/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_1.png b/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_1.png new file mode 100644 index 000000000000..ebaf223c813e --- /dev/null +++ b/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a865da4efeadbda43bc608227b216f7f38be9cbb89b3d31119694d7e3ab9f93 +size 25993 diff --git a/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_2.png b/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_2.png new file mode 100644 index 000000000000..72f44df44b15 --- /dev/null +++ b/crates/viewer/re_viewer/tests/snapshots/blueprint_load_into_new_context_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dad6e76ac2a4fa234a5233df1ad055147b794740891b59b277ed72e2feb40562 +size 30429 diff --git a/crates/viewer/re_viewer_context/src/store_hub.rs b/crates/viewer/re_viewer_context/src/store_hub.rs index 52905c6ac30b..58bc553be562 100644 --- a/crates/viewer/re_viewer_context/src/store_hub.rs +++ b/crates/viewer/re_viewer_context/src/store_hub.rs @@ -909,38 +909,49 @@ impl StoreHub { fn try_to_load_persisted_blueprint(&mut self, app_id: &ApplicationId) -> anyhow::Result<()> { re_tracing::profile_function!(); - let Some(loader) = &self.persistence.loader else { - return Ok(()); - }; + if let Some(loader) = &self.persistence.loader + && let Some(bundle) = (loader)(app_id)? + { + self.load_blueprint_store(bundle, app_id)?; + } - if let Some(mut bundle) = (loader)(app_id)? { - for store in bundle.drain_entity_dbs() { - match store.store_kind() { - StoreKind::Recording => { - anyhow::bail!( - "Found a recording in a blueprint file: {:?}", - store.store_id() - ); - } - StoreKind::Blueprint => {} - } + Ok(()) + } + + /// Load a blueprint and make it active for the given `ApplicationId`. + pub fn load_blueprint_store( + &mut self, + mut blueprint_bundle: StoreBundle, + app_id: &ApplicationId, + ) -> anyhow::Result<()> { + re_tracing::profile_function!(); - if store.application_id() != app_id { - anyhow::bail!("Found app_id {}; expected {app_id}", store.application_id()); + for store in blueprint_bundle.drain_entity_dbs() { + match store.store_kind() { + StoreKind::Recording => { + anyhow::bail!( + "Found a recording in a blueprint file: {:?}", + store.store_id() + ); } + StoreKind::Blueprint => {} + } - // We found the blueprint we were looking for; make it active. - // borrow-checker won't let us just call `self.set_blueprint_for_app_id` - re_log::debug!( - "Activating new blueprint {:?} for {app_id}; loaded from disk", - store.store_id(), - ); - self.active_blueprint_by_app_id - .insert(app_id.clone(), store.store_id().clone()); - self.blueprint_last_save - .insert(store.store_id().clone(), store.generation()); - self.store_bundle.insert(store); + if store.application_id() != app_id { + anyhow::bail!("Found app_id {}; expected {app_id}", store.application_id()); } + + // We found the blueprint we were looking for; make it active. + // borrow-checker won't let us just call `self.set_blueprint_for_app_id` + re_log::debug!( + "Activating new blueprint {:?} for {app_id}.", + store.store_id(), + ); + self.active_blueprint_by_app_id + .insert(app_id.clone(), store.store_id().clone()); + self.blueprint_last_save + .insert(store.store_id().clone(), store.generation()); + self.store_bundle.insert(store); } Ok(())