From a6443d9950bb176f5e08acc2a5c4642805ced299 Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:09:31 +0100 Subject: [PATCH 1/6] stream metadata catalog part1 --- .../proto/rerun/v0/remote_store.proto | 45 ++--- .../re_protos/src/v0/rerun.remote_store.v0.rs | 188 ++++++------------ 2 files changed, 75 insertions(+), 158 deletions(-) diff --git a/crates/store/re_protos/proto/rerun/v0/remote_store.proto b/crates/store/re_protos/proto/rerun/v0/remote_store.proto index 6b156a4b3eda..7052a6b6a21a 100644 --- a/crates/store/re_protos/proto/rerun/v0/remote_store.proto +++ b/crates/store/re_protos/proto/rerun/v0/remote_store.proto @@ -10,9 +10,8 @@ service StorageNode { rpc FetchRecording(FetchRecordingRequest) returns (stream FetchRecordingResponse) {} // metadata API calls - rpc ListRecordings(ListRecordingsRequest) returns (ListRecordingsResponse) {} - rpc GetRecordingMetadata(GetRecordingMetadataRequest) returns (GetRecordingMetadataResponse) {} - rpc UpdateRecordingMetadata(UpdateRecordingMetadataRequest) returns (UpdateRecordingMetadataResponse) {} + rpc QueryCatalog(QueryCatalogRequest) returns (stream QueryCatalogResponse) {} + rpc UpdateCatalog(UpdateCatalogRequest) returns (UpdateCatalogResponse) {} rpc RegisterRecording(RegisterRecordingRequest) returns (RegisterRecordingResponse) {} } @@ -45,30 +44,14 @@ message RegisterRecordingResponse { RecordingMetadata metadata = 2; } -// ---------------- GetRecordingMetadata ----------------- +// ---------------- UpdateCatalog ----------------- -message GetRecordingMetadataRequest { +message UpdateCatalogRequest { RecordingId recording_id = 1; -} - -message GetRecordingMetadataResponse { - RecordingId id = 1; RecordingMetadata metadata = 2; } -message TimeMetadata { - Timeline timeline = 1; - TimeRange time_range = 2; -} - -// ---------------- UpdateRecordingMetadata ----------------- - -message UpdateRecordingMetadataRequest { - RecordingId recording_id = 1; - RecordingMetadata metadata = 2; -} - -message UpdateRecordingMetadataResponse {} +message UpdateCatalogResponse {} // ---------------- Query ----------------- @@ -94,21 +77,27 @@ enum EncoderVersion { } -// ----------------- ListRecordings ----------------- +// ----------------- QueryCatalog ----------------- -message ListRecordingsRequest { +message QueryCatalogRequest { // define which columns should be returned / projected // we define a separate message to make it optional. // If not provided, all columns should be returned ColumnProjection column_projection = 1; + // filter out specific recordings metadata + CatalogFilter filter = 2; } message ColumnProjection { repeated string columns = 1; } -message ListRecordingsResponse { - repeated RecordingMetadata recordings = 1; +message CatalogFilter { + // filter out specific recordings + repeated RecordingId recording_ids = 1; +} + +message QueryCatalogResponse { } enum RecordingType { @@ -132,7 +121,9 @@ message FetchRecordingResponse { bytes payload = 2; } -// Application level error - use as `details` in the `google.rpc.Status` message +// ----------------- Error handling ----------------- + +// Application level error - used as `details` in the `google.rpc.Status` message message RemoteStoreError { // error code ErrorCode code = 1; diff --git a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs index bb0c4bd235f3..4ef610350e33 100644 --- a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs +++ b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs @@ -245,33 +245,14 @@ pub struct RegisterRecordingResponse { pub metadata: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetRecordingMetadataRequest { - #[prost(message, optional, tag = "1")] - pub recording_id: ::core::option::Option, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetRecordingMetadataResponse { - #[prost(message, optional, tag = "1")] - pub id: ::core::option::Option, - #[prost(message, optional, tag = "2")] - pub metadata: ::core::option::Option, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct TimeMetadata { - #[prost(message, optional, tag = "1")] - pub timeline: ::core::option::Option, - #[prost(message, optional, tag = "2")] - pub time_range: ::core::option::Option, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct UpdateRecordingMetadataRequest { +pub struct UpdateCatalogRequest { #[prost(message, optional, tag = "1")] pub recording_id: ::core::option::Option, #[prost(message, optional, tag = "2")] pub metadata: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct UpdateRecordingMetadataResponse {} +pub struct UpdateCatalogResponse {} #[derive(Clone, PartialEq, ::prost::Message)] pub struct QueryRequest { /// unique identifier of the recording @@ -293,12 +274,15 @@ pub struct QueryResponse { pub payload: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListRecordingsRequest { +pub struct QueryCatalogRequest { /// define which columns should be returned / projected /// we define a separate message to make it optional. /// If not provided, all columns should be returned #[prost(message, optional, tag = "1")] pub column_projection: ::core::option::Option, + /// filter out specific recordings metadata + #[prost(message, optional, tag = "2")] + pub filter: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ColumnProjection { @@ -306,10 +290,13 @@ pub struct ColumnProjection { pub columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListRecordingsResponse { +pub struct CatalogFilter { + /// filter out specific recordings #[prost(message, repeated, tag = "1")] - pub recordings: ::prost::alloc::vec::Vec, + pub recording_ids: ::prost::alloc::vec::Vec, } +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct QueryCatalogResponse {} #[derive(Clone, PartialEq, ::prost::Message)] pub struct FetchRecordingRequest { #[prost(message, optional, tag = "1")] @@ -328,7 +315,7 @@ pub struct FetchRecordingResponse { #[prost(bytes = "vec", tag = "2")] pub payload: ::prost::alloc::vec::Vec, } -/// Application level error - use as `details` in the `google.rpc.Status` message +/// Application level error - used as `details` in the `google.rpc.Status` message #[derive(Clone, PartialEq, ::prost::Message)] pub struct RemoteStoreError { /// error code @@ -546,62 +533,43 @@ pub mod storage_node_client { self.inner.server_streaming(req, path, codec).await } /// metadata API calls - pub async fn list_recordings( + pub async fn query_catalog( &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> - { + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { self.inner.ready().await.map_err(|e| { tonic::Status::unknown(format!("Service was not ready: {}", e.into())) })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/rerun.remote_store.v0.StorageNode/ListRecordings", + "/rerun.remote_store.v0.StorageNode/QueryCatalog", ); let mut req = request.into_request(); req.extensions_mut().insert(GrpcMethod::new( "rerun.remote_store.v0.StorageNode", - "ListRecordings", + "QueryCatalog", )); - self.inner.unary(req, path, codec).await + self.inner.server_streaming(req, path, codec).await } - pub async fn get_recording_metadata( + pub async fn update_catalog( &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { self.inner.ready().await.map_err(|e| { tonic::Status::unknown(format!("Service was not ready: {}", e.into())) })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/rerun.remote_store.v0.StorageNode/GetRecordingMetadata", + "/rerun.remote_store.v0.StorageNode/UpdateCatalog", ); let mut req = request.into_request(); req.extensions_mut().insert(GrpcMethod::new( "rerun.remote_store.v0.StorageNode", - "GetRecordingMetadata", - )); - self.inner.unary(req, path, codec).await - } - pub async fn update_recording_metadata( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner.ready().await.map_err(|e| { - tonic::Status::unknown(format!("Service was not ready: {}", e.into())) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/rerun.remote_store.v0.StorageNode/UpdateRecordingMetadata", - ); - let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new( - "rerun.remote_store.v0.StorageNode", - "UpdateRecordingMetadata", + "UpdateCatalog", )); self.inner.unary(req, path, codec).await } @@ -658,22 +626,20 @@ pub mod storage_node_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + /// Server streaming response type for the QueryCatalog method. + type QueryCatalogStream: tonic::codegen::tokio_stream::Stream< + Item = std::result::Result, + > + std::marker::Send + + 'static; /// metadata API calls - async fn list_recordings( + async fn query_catalog( &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - async fn get_recording_metadata( + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn update_catalog( &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - async fn update_recording_metadata( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn register_recording( &self, request: tonic::Request, @@ -836,63 +802,24 @@ pub mod storage_node_server { }; Box::pin(fut) } - "/rerun.remote_store.v0.StorageNode/ListRecordings" => { - #[allow(non_camel_case_types)] - struct ListRecordingsSvc(pub Arc); - impl tonic::server::UnaryService - for ListRecordingsSvc - { - type Response = super::ListRecordingsResponse; - type Future = BoxFuture, tonic::Status>; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::list_recordings(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = ListRecordingsSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/rerun.remote_store.v0.StorageNode/GetRecordingMetadata" => { + "/rerun.remote_store.v0.StorageNode/QueryCatalog" => { #[allow(non_camel_case_types)] - struct GetRecordingMetadataSvc(pub Arc); + struct QueryCatalogSvc(pub Arc); impl - tonic::server::UnaryService - for GetRecordingMetadataSvc + tonic::server::ServerStreamingService + for QueryCatalogSvc { - type Response = super::GetRecordingMetadataResponse; - type Future = BoxFuture, tonic::Status>; + type Response = super::QueryCatalogResponse; + type ResponseStream = T::QueryCatalogStream; + type Future = + BoxFuture, tonic::Status>; fn call( &mut self, - request: tonic::Request, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::get_recording_metadata(&inner, request).await + ::query_catalog(&inner, request).await }; Box::pin(fut) } @@ -903,7 +830,7 @@ pub mod storage_node_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = GetRecordingMetadataSvc(inner); + let method = QueryCatalogSvc(inner); let codec = tonic::codec::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -914,27 +841,26 @@ pub mod storage_node_server { max_decoding_message_size, max_encoding_message_size, ); - let res = grpc.unary(method, req).await; + let res = grpc.server_streaming(method, req).await; Ok(res) }; Box::pin(fut) } - "/rerun.remote_store.v0.StorageNode/UpdateRecordingMetadata" => { + "/rerun.remote_store.v0.StorageNode/UpdateCatalog" => { #[allow(non_camel_case_types)] - struct UpdateRecordingMetadataSvc(pub Arc); - impl - tonic::server::UnaryService - for UpdateRecordingMetadataSvc + struct UpdateCatalogSvc(pub Arc); + impl tonic::server::UnaryService + for UpdateCatalogSvc { - type Response = super::UpdateRecordingMetadataResponse; + type Response = super::UpdateCatalogResponse; type Future = BoxFuture, tonic::Status>; fn call( &mut self, - request: tonic::Request, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::update_recording_metadata(&inner, request).await + ::update_catalog(&inner, request).await }; Box::pin(fut) } @@ -945,7 +871,7 @@ pub mod storage_node_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = UpdateRecordingMetadataSvc(inner); + let method = UpdateCatalogSvc(inner); let codec = tonic::codec::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( From 49e6f7b058485f107e1354b58c59da1e005ac885 Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:25:47 +0100 Subject: [PATCH 2/6] add missing query catalog response --- crates/store/re_protos/proto/rerun/v0/remote_store.proto | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/store/re_protos/proto/rerun/v0/remote_store.proto b/crates/store/re_protos/proto/rerun/v0/remote_store.proto index 7052a6b6a21a..cd189f3b1c22 100644 --- a/crates/store/re_protos/proto/rerun/v0/remote_store.proto +++ b/crates/store/re_protos/proto/rerun/v0/remote_store.proto @@ -98,6 +98,9 @@ message CatalogFilter { } message QueryCatalogResponse { + EncoderVersion encoder_version = 1; + // raw bytes are TransportChunks (i.e. RecordBatches) encoded with the relevant codec + bytes payload = 2; } enum RecordingType { From bea14a2a6e05945566a92425db607d46e787c7aa Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:40:29 +0100 Subject: [PATCH 3/6] add missing query catalog response again --- crates/store/re_protos/src/v0/rerun.remote_store.v0.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs index 4ef610350e33..731659e42c64 100644 --- a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs +++ b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs @@ -295,8 +295,14 @@ pub struct CatalogFilter { #[prost(message, repeated, tag = "1")] pub recording_ids: ::prost::alloc::vec::Vec, } -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct QueryCatalogResponse {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct QueryCatalogResponse { + #[prost(enumeration = "EncoderVersion", tag = "1")] + pub encoder_version: i32, + /// raw bytes are TransportChunks (i.e. RecordBatches) encoded with the relevant codec + #[prost(bytes = "vec", tag = "2")] + pub payload: ::prost::alloc::vec::Vec, +} #[derive(Clone, PartialEq, ::prost::Message)] pub struct FetchRecordingRequest { #[prost(message, optional, tag = "1")] From d4ef4ed26e3ecf46fb167cf94c5e7712fbc02ca2 Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:56:32 +0100 Subject: [PATCH 4/6] update python bindings --- examples/python/remote/metadata.py | 4 +-- rerun_py/rerun_bindings/rerun_bindings.pyi | 4 +-- rerun_py/src/remote.rs | 39 ++++++++++++---------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/examples/python/remote/metadata.py b/examples/python/remote/metadata.py index 6affe479e413..e199499d8f2a 100644 --- a/examples/python/remote/metadata.py +++ b/examples/python/remote/metadata.py @@ -25,7 +25,7 @@ # Register the new rrd conn = rr.remote.connect("http://0.0.0.0:51234") - catalog = pl.from_arrow(conn.list_recordings()) + catalog = pl.from_arrow(conn.query_catalog()) if args.subcommand == "print": print(catalog) @@ -38,4 +38,4 @@ exit(1) print(f"Updating metadata for {id}") - conn.update_metadata(id, {args.key: pa.array([args.value])}) + conn.update_catalog(id, {args.key: pa.array([args.value])}) diff --git a/rerun_py/rerun_bindings/rerun_bindings.pyi b/rerun_py/rerun_bindings/rerun_bindings.pyi index dbe45bb9e285..47866fe13399 100644 --- a/rerun_py/rerun_bindings/rerun_bindings.pyi +++ b/rerun_py/rerun_bindings/rerun_bindings.pyi @@ -575,7 +575,7 @@ class StorageNodeClient: Required-feature: `remote` """ - def list_recordings(self) -> pa.RecordBatchReader: + def query_catalog(self) -> pa.RecordBatchReader: """Get the metadata for all recordings in the storage node.""" ... @@ -593,7 +593,7 @@ class StorageNodeClient: """ ... - def update_metadata(self, id: str, metadata: dict[str, MetadataLike]) -> None: + def update_catalog(self, id: str, metadata: dict[str, MetadataLike]) -> None: """ Update the metadata for the recording with the given id. diff --git a/rerun_py/src/remote.rs b/rerun_py/src/remote.rs index eeaf24c652a7..abb95fc12e1f 100644 --- a/rerun_py/src/remote.rs +++ b/rerun_py/src/remote.rs @@ -14,11 +14,12 @@ use re_protos::{ codec::decode, v0::{ storage_node_client::StorageNodeClient, EncoderVersion, FetchRecordingRequest, - ListRecordingsRequest, RecordingId, RecordingMetadata, RecordingType, - RegisterRecordingRequest, UpdateRecordingMetadataRequest, + QueryCatalogRequest, RecordingId, RecordingMetadata, RecordingType, + RegisterRecordingRequest, UpdateCatalogRequest, }, }; use re_sdk::{ApplicationId, StoreId, StoreKind, Time}; +use tokio_stream::StreamExt; use crate::dataframe::PyRecording; @@ -80,26 +81,30 @@ pub struct PyStorageNodeClient { #[pymethods] impl PyStorageNodeClient { - /// Get the metadata for all recordings in the storage node. - fn list_recordings(&mut self) -> PyResult>> { + /// Query the recordings metadata catalog. + fn query_catalog(&mut self) -> PyResult>> { let reader = self.runtime.block_on(async { - // TODO(jleibs): Support column projection - let request = ListRecordingsRequest { + // TODO(jleibs): Support column projection and filtering + let request = QueryCatalogRequest { column_projection: None, + filter: None, }; - let resp = self + let transport_chunks = self .client - .list_recordings(request) + .query_catalog(request) .await - .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; - - let transport_chunks = resp + .map_err(|err| PyRuntimeError::new_err(err.to_string()))? .into_inner() - .recordings - .into_iter() - .map(|recording| recording.data()) + .filter_map(|resp| { + resp.and_then(|r| { + decode(r.encoder_version(), &r.payload) + .map_err(|err| tonic::Status::internal(err.to_string())) + }) + .transpose() + }) .collect::, _>>() + .await .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; let record_batches: Vec> = @@ -216,7 +221,7 @@ impl PyStorageNodeClient { id, metadata ))] - fn update_metadata(&mut self, id: &str, metadata: &Bound<'_, PyDict>) -> PyResult<()> { + fn update_catalog(&mut self, id: &str, metadata: &Bound<'_, PyDict>) -> PyResult<()> { self.runtime.block_on(async { let (schema, data): ( Vec, @@ -247,13 +252,13 @@ impl PyStorageNodeClient { let metadata = RecordingMetadata::try_from(EncoderVersion::V0, &metadata_tc) .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; - let request = UpdateRecordingMetadataRequest { + let request = UpdateCatalogRequest { recording_id: Some(RecordingId { id: id.to_owned() }), metadata: Some(metadata), }; self.client - .update_recording_metadata(request) + .update_catalog(request) .await .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; From 5248fdd4b600c46ae0cdc48fd8794bb01ee87378 Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:59:05 +0100 Subject: [PATCH 5/6] rebase main --- .github/workflows/release.yml | 6 +- CHANGELOG.md | 7 + Cargo.lock | 37 +-- Cargo.toml | 14 +- RELEASES.md | 105 +++--- .../re_dev_tools/src/build_web_viewer/lib.rs | 9 +- .../re_dev_tools/src/build_web_viewer/mod.rs | 5 + .../re_selection_panel/src/item_title.rs | 228 +++++++++++++ crates/viewer/re_selection_panel/src/lib.rs | 2 + .../re_selection_panel/src/selection_panel.rs | 252 +-------------- crates/viewer/re_space_view/src/lib.rs | 4 +- .../re_space_view/src/view_property_ui.rs | 220 ++++++------- crates/viewer/re_space_view_graph/Cargo.toml | 1 + .../src/graph/{index.rs => ids.rs} | 20 ++ .../re_space_view_graph/src/graph/mod.rs | 33 +- .../src/layout/geometry.rs | 76 +++++ .../re_space_view_graph/src/layout/mod.rs | 5 +- .../src/layout/provider.rs | 304 +++++++++++------- .../re_space_view_graph/src/layout/request.rs | 39 ++- .../re_space_view_graph/src/layout/result.rs | 34 +- .../re_space_view_graph/src/layout/slots.rs | 63 ++++ .../viewer/re_space_view_graph/src/ui/draw.rs | 119 ++++--- .../viewer/re_space_view_graph/src/ui/mod.rs | 2 +- .../re_space_view_graph/src/ui/state.rs | 57 ++-- crates/viewer/re_space_view_graph/src/view.rs | 32 +- .../src/visualizers/edges.rs | 26 +- crates/viewer/re_viewer/Cargo.toml | 2 +- crates/viewer/re_viewer_context/src/item.rs | 14 +- .../space_view_class_placeholder.rs | 25 +- .../src/view_properties.rs | 15 +- .../src/viewport_blueprint.rs | 11 + pixi.toml | 8 +- rerun_js/web-viewer/build-wasm.mjs | 3 +- .../check_graph_time_layout.py | 63 ++++ .../release_checklist/check_graph_view.py | 14 +- .../check_graph_view_multi_self_edges.py | 78 +++++ 36 files changed, 1204 insertions(+), 729 deletions(-) create mode 100644 crates/viewer/re_selection_panel/src/item_title.rs rename crates/viewer/re_space_view_graph/src/graph/{index.rs => ids.rs} (71%) create mode 100644 crates/viewer/re_space_view_graph/src/layout/geometry.rs create mode 100644 crates/viewer/re_space_view_graph/src/layout/slots.rs create mode 100644 tests/python/release_checklist/check_graph_time_layout.py create mode 100644 tests/python/release_checklist/check_graph_view_multi_self_edges.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eda79d8bbc83..0a8a9d541f62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,6 +190,7 @@ jobs: - [ ] C++ SDK zip: does it contain rerun_c for all platforms? - [ ] Populate the release with the changelog and a nice header video/picture, check `Set as latest release`, then click `Publish release`. - [ ] Update the [google colab notebooks](https://drive.google.com/drive/folders/0AC0q24MFKh3fUk9PVA) to install this version and re-execute the notebook. + - [ ] Update landing's version of the web viewer (@jprochazk) A few hours after the GitHub release is created, `regro-cf-autotick-bot` will create a [conda feedstock PR](https://github.com/conda-forge/rerun-sdk-feedstock/pulls). @@ -360,10 +361,7 @@ jobs: cat < comment-body.txt GitHub release draft: [$version](https://github.com/rerun-io/rerun/releases/tag/$version) - Do NOT create a GitHub release yet! - - The release will be automatically un-drafted by the "Sync Release Assets" job, which will run automatically. - EOF + Add a description, changelog, and a nice header video/picture, then click 'Publish release'. gh pr comment $pr_number --body-file comment-body.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2771bf0f4a32..a94fc73a0c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased](https://github.com/rerun-io/rerun/compare/latest...HEAD) +## [0.20.3](https://github.com/rerun-io/rerun/compare/0.20.2...0.20.3) - Web viewer fix + +### 🔎 Details + +#### 🪳 Bug fixes +- Fix web viewer feature flags [#8295](https://github.com/rerun-io/rerun/pull/8295) + ## [0.20.2](https://github.com/rerun-io/rerun/compare/0.20.1...0.20.2) - Build fix ### 🔎 Details diff --git a/Cargo.lock b/Cargo.lock index a5d7e947a821..b071cb0aacb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f47983a1084940ba9a39c077a8c63e55c619388be5476ac04c804cfbd1e63459" dependencies = [ "accesskit", - "hashbrown 0.15.0", + "hashbrown 0.15.1", "immutable-chunkmap", ] @@ -71,7 +71,7 @@ checksum = "7329821f3bd1101e03a7d2e03bd339e3ac0dc64c70b4c9f9ae1949e3ba8dece1" dependencies = [ "accesskit", "accesskit_consumer 0.26.0", - "hashbrown 0.15.0", + "hashbrown 0.15.1", "objc2", "objc2-app-kit", "objc2-foundation", @@ -103,7 +103,7 @@ checksum = "24fcd5d23d70670992b823e735e859374d694a3d12bfd8dd32bd3bd8bedb5d81" dependencies = [ "accesskit", "accesskit_consumer 0.26.0", - "hashbrown 0.15.0", + "hashbrown 0.15.1", "paste", "static_assertions", "windows 0.58.0", @@ -1923,7 +1923,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "ecolor" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "bytemuck", "color-hex", @@ -1940,7 +1940,7 @@ checksum = "18aade80d5e09429040243ce1143ddc08a92d7a22820ac512610410a4dd5214f" [[package]] name = "eframe" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "ahash", "bytemuck", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "egui" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "accesskit", "ahash", @@ -1996,7 +1996,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "ahash", "bytemuck", @@ -2015,7 +2015,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "accesskit_winit", "ahash", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "ahash", "egui", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "ahash", "bytemuck", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "dify", "egui", @@ -2161,7 +2161,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "bytemuck", "serde", @@ -2277,7 +2277,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" dependencies = [ "ab_glyph", "ahash", @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=eac7ba01fa37bad35f71bc303561761952361b7c#eac7ba01fa37bad35f71bc303561761952361b7c" +source = "git+https://github.com/emilk/egui.git?rev=c5ac7d301a90afbaec843ee04fb43d8a0956cc90#c5ac7d301a90afbaec843ee04fb43d8a0956cc90" [[package]] name = "equivalent" @@ -2998,9 +2998,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" dependencies = [ "allocator-api2", "equivalent", @@ -3409,7 +3409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown 0.15.1", "serde", ] @@ -3826,7 +3826,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.0", + "hashbrown 0.15.1", ] [[package]] @@ -6242,6 +6242,7 @@ dependencies = [ "fjadra", "nohash-hasher", "re_chunk", + "re_data_ui", "re_entity_db", "re_format", "re_log_types", diff --git a/Cargo.toml b/Cargo.toml index 061d2ac225bb..3a8b3275fd06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -559,13 +559,13 @@ significant_drop_tightening = "allow" # An update of parking_lot made this trigg # As a last resport, patch with a commit to our own repository. # ALWAYS document what PR the commit hash is part of, or when it was merged into the upstream trunk. -ecolor = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -eframe = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -egui = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -egui_extras = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -egui_kittest = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -egui-wgpu = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 -emath = { git = "https://github.com/emilk/egui.git", rev = "eac7ba01fa37bad35f71bc303561761952361b7c" } # egui master 2024-12-03 +ecolor = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +eframe = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +egui = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +egui_extras = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +egui_kittest = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +egui-wgpu = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 +emath = { git = "https://github.com/emilk/egui.git", rev = "c5ac7d301a90afbaec843ee04fb43d8a0956cc90" } # egui master 2024-12-03 # Useful while developing: # ecolor = { path = "../../egui/crates/ecolor" } diff --git a/RELEASES.md b/RELEASES.md index 06ca2a9443c3..e3d34358ba3a 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -48,74 +48,85 @@ If we are doing a patch release, we do a branch off of the latest release tag (e # Release process -1. ### Check the root [`Cargo.toml`](/Cargo.toml) to see what version we are currently on. +### 1. Check the root [`Cargo.toml`](/Cargo.toml) to see what version we are currently on. -2. ### Create a release branch. +### 2. Create a release branch. - The name should be: - - `release-0.x.y` for final releases and their release candidates. - - `release-0.x.y-alpha.N` where `N` is incremented from the previous alpha, - or defaulted to `1` if no previous alpha exists. +The name should be: +- `release-0.x.y` for final releases and their release candidates. +- `release-0.x.y-alpha.N` where `N` is incremented from the previous alpha, + or defaulted to `1` if no previous alpha exists. - Note that `release-0.x` is _invalid_. Always specify the `y`, even if it is `0`, - e.g. `release-0.15.0` instead of `release-0.15`. +Note that `release-0.x` is _invalid_. Always specify the `y`, even if it is `0`, +e.g. `release-0.15.0` instead of `release-0.15`. - For minor release, the branch is typically created from `main`. For patch release, the branch is typically created - from the previous release's tag. +For minor release, the branch is typically created from `main`. For patch release, the branch is typically created +from the previous release's tag. - ![Image showing the branch create UI. You can find the `new branch` button at https://github.com/rerun-io/rerun/branches](https://github.com/rerun-io/rerun/assets/1665677/becaad03-9262-4476-b811-c23d40305aec) +![Image showing the branch create UI. You can find the `new branch` button at https://github.com/rerun-io/rerun/branches](https://github.com/rerun-io/rerun/assets/1665677/becaad03-9262-4476-b811-c23d40305aec) - Note: you do not need to create a PR for this branch -- the release workflow will do that for you. +Note: you do not need to create a PR for this branch -- the release workflow will do that for you. -3. ### If this is a patch release, cherry-pick commits for inclusion in the release into the branch. +### 3. If this is a patch release, cherry-pick commits for inclusion in the release into the branch. - When done, run [`cargo semver-checks`](https://github.com/obi1kenobi/cargo-semver-checks) to check that we haven't introduced any semver breaking changes. +When done, run [`cargo semver-checks`](https://github.com/obi1kenobi/cargo-semver-checks) to check that we haven't introduced any semver breaking changes. - :warning: Any commits between the last release's tag and the `docs-latest` branch should also be cherry-picked. - Otherwise, these changes will be lost when `docs-latest` is updated. +:warning: Any commits between the last release's tag and the `docs-latest` branch should also be cherry-picked, +otherwise these changes will be lost when `docs-latest` is updated. -4. ### Update [`CHANGELOG.md`](/CHANGELOG.md) and clean ups. +``` +# On branch `release-0.x.y` +git fetch origin docs-latest:docs-latest +git cherry-pick 0.x.z..docs-latest +``` - Update the change log. It should include: - - A one-line summary of the release - - A multi-line summary of the release - - A gif showing a major new feature - - Run `pip install GitPython && scripts/generate_changelog.py > new_changelog.md` - - Edit PR descriptions/labels to improve the generated changelog - - Copy-paste the results into `CHANGELOG.md`. - - Editorialize the changelog if necessary - - Make sure the changelog includes instructions for handling any breaking changes +Where `z` is the previous patch number. - Remove the speculative link markers and the `attr.docs.unreleased` attributes in the .fbs files. +Note that the `cherry-pick` will fail if there are no additional `docs-latest` commits to include, +which is fine. - Once you're done, commit and push onto the release branch. +### 4. Update [`CHANGELOG.md`](/CHANGELOG.md) and clean ups. -5. ### Run the [release workflow](https://github.com/rerun-io/rerun/actions/workflows/release.yml). +Update the change log. It should include: + - A one-line summary of the release + - A multi-line summary of the release + - A gif showing a major new feature + - Run `pip install GitPython && scripts/generate_changelog.py > new_changelog.md` + - Edit PR descriptions/labels to improve the generated changelog + - Copy-paste the results into `CHANGELOG.md`. + - Editorialize the changelog if necessary + - Make sure the changelog includes instructions for handling any breaking changes - In the UI: - - Set `Use workflow from` to the release branch you created in step (2). - - Then choose one of the following values in the dropdown: - - `alpha` if the branch name is `release-x.y.z-alpha.N`. - This will create a one-off alpha release. +Remove the speculative link markers and the `attr.docs.unreleased` attributes in the .fbs files. - - `rc` if the branch name is `release-x.y.z`. - This will create a pull request for the release, and publish a release candidate. +Once you're done, commit and push onto the release branch. - - `final` for the final public release +### 5. Run the [release workflow](https://github.com/rerun-io/rerun/actions/workflows/release.yml). - ![Image showing the Run workflow UI. It can be found at https://github.com/rerun-io/rerun/actions/workflows/release.yml](https://github.com/rerun-io/rerun/assets/1665677/6cdc8e7e-c0fc-4cf1-99cb-0749957b8328) +In the UI: +- Set `Use workflow from` to the release branch you created in step (2). +- Then choose one of the following values in the dropdown: + - `alpha` if the branch name is `release-x.y.z-alpha.N`. + This will create a one-off alpha release. -6. ### Wait for the workflow to finish + - `rc` if the branch name is `release-x.y.z`. + This will create a pull request for the release, and publish a release candidate. - The PR description will contain next steps. + - `final` for the final public release - Note: there are two separate workflows running -- the one building the release artifacts, and the one running the PR checks. - You will have to wait for the [former](https://github.com/rerun-io/rerun/actions/workflows/release.yml) in order to get a link to the artifacts. +![Image showing the Run workflow UI. It can be found at https://github.com/rerun-io/rerun/actions/workflows/release.yml](https://github.com/rerun-io/rerun/assets/1665677/6cdc8e7e-c0fc-4cf1-99cb-0749957b8328) -7. ### Merge changes to `main` +### 6. Wait for the workflow to finish - For minor release, merge the release branch to `main`. +The PR description will contain next steps. - For patch release, manually create a new PR from `main` and cherry-pick the required commits. This includes at least - the `CHANLGE.log` update, plus any other changes made on the release branch that hasn't been cherry-picked in the - first place. +Note: there are two separate workflows running -- the one building the release artifacts, and the one running the PR checks. +You will have to wait for the [former](https://github.com/rerun-io/rerun/actions/workflows/release.yml) in order to get a link to the artifacts. + +### 7. Merge changes to `main` + +For minor release, merge the release branch to `main`. + +For patch release, manually create a new PR from `main` and cherry-pick the required commits. This includes at least +the `CHANLGE.log` update, plus any other changes made on the release branch that hasn't been cherry-picked in the +first place. diff --git a/crates/build/re_dev_tools/src/build_web_viewer/lib.rs b/crates/build/re_dev_tools/src/build_web_viewer/lib.rs index 5014bb59fe01..3a0973bc56bc 100644 --- a/crates/build/re_dev_tools/src/build_web_viewer/lib.rs +++ b/crates/build/re_dev_tools/src/build_web_viewer/lib.rs @@ -74,6 +74,7 @@ pub fn build( debug_symbols: bool, target: Target, build_dir: &Utf8Path, + no_default_features: bool, features: &String, ) -> anyhow::Result<()> { std::env::set_current_dir(workspace_root())?; @@ -118,9 +119,13 @@ pub fn build( "--lib", "--target=wasm32-unknown-unknown", &format!("--target-dir={}", target_wasm_dir.as_str()), - "--no-default-features", - &format!("--features={features}"), ]); + if no_default_features { + cmd.arg("--no-default-features"); + } + if !features.is_empty() { + cmd.arg(&format!("--features={features}")); + } if profile == Profile::Release { cmd.arg("--release"); } diff --git a/crates/build/re_dev_tools/src/build_web_viewer/mod.rs b/crates/build/re_dev_tools/src/build_web_viewer/mod.rs index 1a1484c05e5f..614f09f04d2f 100644 --- a/crates/build/re_dev_tools/src/build_web_viewer/mod.rs +++ b/crates/build/re_dev_tools/src/build_web_viewer/mod.rs @@ -38,6 +38,10 @@ pub struct Args { /// comma-separated list of features to pass on to `re_viewer` #[argh(option, short = 'F', long = "features", default = "default_features()")] features: String, + + /// whether to exclude default features from `re_viewer` wasm build + #[argh(switch, long = "no-default-features")] + no_default_features: bool, } fn default_features() -> String { @@ -62,6 +66,7 @@ pub fn main(args: Args) -> anyhow::Result<()> { args.debug_symbols, args.target, &build_dir, + args.no_default_features, &args.features, ) } diff --git a/crates/viewer/re_selection_panel/src/item_title.rs b/crates/viewer/re_selection_panel/src/item_title.rs new file mode 100644 index 000000000000..9204a4e81006 --- /dev/null +++ b/crates/viewer/re_selection_panel/src/item_title.rs @@ -0,0 +1,228 @@ +use re_data_ui::item_ui::{guess_instance_path_icon, guess_query_and_db_for_selected_entity}; +use re_ui::{icons, list_item, DesignTokens, SyntaxHighlighting as _, UiExt as _}; +use re_viewer_context::{contents_name_style, Item, SystemCommandSender as _, ViewerContext}; +use re_viewport_blueprint::ViewportBlueprint; + +#[must_use] +pub struct ItemTitle { + name: egui::WidgetText, + hover: Option, + icon: &'static re_ui::Icon, + label_style: Option, +} + +impl ItemTitle { + pub fn from_item( + ctx: &ViewerContext<'_>, + viewport: &ViewportBlueprint, + style: &egui::Style, + item: &Item, + ) -> Option { + match &item { + Item::AppId(app_id) => { + let title = app_id.to_string(); + Some(Self::new(title, &icons::APPLICATION)) + } + + Item::DataSource(data_source) => { + let title = data_source.to_string(); + Some(Self::new(title, &icons::DATA_SOURCE)) + } + + Item::StoreId(store_id) => { + let id_str = format!("{} ID: {}", store_id.kind, store_id); + + let title = if let Some(entity_db) = ctx.store_context.bundle.get(store_id) { + if let Some(info) = entity_db.store_info() { + let time = info + .started + .format_time_custom( + "[hour]:[minute]:[second]", + ctx.app_options.time_zone, + ) + .unwrap_or("".to_owned()); + + format!("{} - {}", info.application_id, time) + } else { + id_str.clone() + } + } else { + id_str.clone() + }; + + let icon = match store_id.kind { + re_log_types::StoreKind::Recording => &icons::RECORDING, + re_log_types::StoreKind::Blueprint => &icons::BLUEPRINT, + }; + + Some(Self::new(title, icon).with_tooltip(id_str)) + } + + Item::Container(container_id) => { + if let Some(container_blueprint) = viewport.container(container_id) { + let hover_text = + if let Some(display_name) = container_blueprint.display_name.as_ref() { + format!( + "{:?} container {display_name:?}", + container_blueprint.container_kind, + ) + } else { + format!("Unnamed {:?} container", container_blueprint.container_kind,) + }; + + let container_name = container_blueprint.display_name_or_default(); + Some( + Self::new( + container_name.as_ref(), + re_viewer_context::icon_for_container_kind( + &container_blueprint.container_kind, + ), + ) + .with_label_style(contents_name_style(&container_name)) + .with_tooltip(hover_text), + ) + } else { + None + } + } + + Item::ComponentPath(component_path) => { + let entity_path = &component_path.entity_path; + let component_name = &component_path.component_name; + + let (_query, db) = guess_query_and_db_for_selected_entity(ctx, entity_path); + let is_static = db + .storage_engine() + .store() + .entity_has_static_component(entity_path, component_name); + + Some( + Self::new( + component_name.short_name(), + if is_static { + &icons::COMPONENT_STATIC + } else { + &icons::COMPONENT_TEMPORAL + }, + ) + .with_tooltip(format!( + "{} component {} of entity '{}'", + if is_static { "Static" } else { "Temporal" }, + component_name.full_name(), + entity_path + )), + ) + } + + Item::SpaceView(view_id) => { + if let Some(view) = viewport.view(view_id) { + let view_class = view.class(ctx.space_view_class_registry); + + let hover_text = if let Some(display_name) = view.display_name.as_ref() { + format!( + "Space view {:?} of type {}", + display_name, + view_class.display_name() + ) + } else { + format!("Unnamed view of type {}", view_class.display_name()) + }; + + let view_name = view.display_name_or_default(); + + Some( + Self::new( + view_name.as_ref(), + view.class(ctx.space_view_class_registry).icon(), + ) + .with_label_style(contents_name_style(&view_name)) + .with_tooltip(hover_text), + ) + } else { + None + } + } + + Item::InstancePath(instance_path) => { + let typ = item.kind(); + let name = instance_path.syntax_highlighted(style); + + Some( + Self::new(name, guess_instance_path_icon(ctx, instance_path)) + .with_tooltip(format!("{typ} '{instance_path}'")), + ) + } + + Item::DataResult(view_id, instance_path) => { + let name = instance_path.syntax_highlighted(style); + + if let Some(view) = viewport.view(view_id) { + let typ = item.kind(); + Some( + Self::new(name, guess_instance_path_icon(ctx, instance_path)).with_tooltip( + format!( + "{typ} '{instance_path}' as shown in view {:?}", + view.display_name + ), + ), + ) + } else { + None + } + } + } + } + + fn new(name: impl Into, icon: &'static re_ui::Icon) -> Self { + Self { + name: name.into(), + hover: None, + icon, + label_style: None, + } + } + + #[inline] + fn with_tooltip(mut self, hover: impl Into) -> Self { + self.hover = Some(hover.into()); + self + } + + #[inline] + fn with_label_style(mut self, label_style: re_ui::LabelStyle) -> Self { + self.label_style = Some(label_style); + self + } + + pub fn ui(self, ctx: &ViewerContext<'_>, ui: &mut egui::Ui, item: &Item) { + let Self { + name, + hover, + icon, + label_style, + } = self; + + let mut content = list_item::LabelContent::new(name).with_icon(icon); + + if let Some(label_style) = label_style { + content = content.label_style(label_style); + } + + let response = ui + .list_item() + .with_height(DesignTokens::title_bar_height()) + .selected(true) + .show_flat(ui, content); + + if response.clicked() { + // If the user has multiple things selected but only wants to have one thing selected, + // this is how they can do it. + ctx.command_sender + .send_system(re_viewer_context::SystemCommand::SetSelection(item.clone())); + } + + if let Some(hover) = hover { + response.on_hover_text(hover); + } + } +} diff --git a/crates/viewer/re_selection_panel/src/lib.rs b/crates/viewer/re_selection_panel/src/lib.rs index a2d534606742..4517a2783bb3 100644 --- a/crates/viewer/re_selection_panel/src/lib.rs +++ b/crates/viewer/re_selection_panel/src/lib.rs @@ -1,6 +1,7 @@ //! The UI for the selection panel. mod defaults_ui; +mod item_title; mod selection_history_ui; mod selection_panel; mod space_view_entity_picker; @@ -8,6 +9,7 @@ mod space_view_space_origin_ui; mod visible_time_range_ui; mod visualizer_ui; +pub use item_title::ItemTitle; pub use selection_panel::SelectionPanel; #[cfg(test)] diff --git a/crates/viewer/re_selection_panel/src/selection_panel.rs b/crates/viewer/re_selection_panel/src/selection_panel.rs index 1a40519f2bfa..f5997b3db199 100644 --- a/crates/viewer/re_selection_panel/src/selection_panel.rs +++ b/crates/viewer/re_selection_panel/src/selection_panel.rs @@ -2,33 +2,29 @@ use egui::NumExt as _; use egui_tiles::ContainerKind; use re_context_menu::{context_menu_ui_for_item, SelectionUpdateBehavior}; -use re_data_ui::{ - item_ui, - item_ui::{guess_instance_path_icon, guess_query_and_db_for_selected_entity}, - DataUi, -}; +use re_data_ui::{item_ui, item_ui::guess_query_and_db_for_selected_entity, DataUi}; use re_entity_db::{EntityPath, InstancePath}; use re_log_types::EntityPathFilter; use re_types::blueprint::components::Interactive; use re_ui::{ icons, list_item::{self, PropertyContent}, - ContextExt as _, DesignTokens, SyntaxHighlighting as _, UiExt, + ContextExt as _, UiExt, }; use re_viewer_context::{ contents_name_style, icon_for_container_kind, ContainerId, Contents, DataQueryResult, - DataResult, HoverHighlight, Item, SpaceViewId, SystemCommandSender, UiLayout, ViewContext, - ViewStates, ViewerContext, + DataResult, HoverHighlight, Item, SpaceViewId, UiLayout, ViewContext, ViewStates, + ViewerContext, }; use re_viewport_blueprint::{ui::show_add_space_view_or_container_modal, ViewportBlueprint}; -use crate::space_view_entity_picker::SpaceViewEntityPicker; use crate::{defaults_ui::view_components_defaults_section_ui, visualizer_ui::visualizer_ui}; use crate::{ selection_history_ui::SelectionHistoryUi, visible_time_range_ui::visible_time_range_ui_for_data_result, visible_time_range_ui::visible_time_range_ui_for_view, }; +use crate::{space_view_entity_picker::SpaceViewEntityPicker, ItemTitle}; // --- fn default_selection_panel_width(screen_width: f32) -> f32 { @@ -128,6 +124,12 @@ impl SelectionPanel { }; for (i, item) in selection.iter_items().enumerate() { list_item::list_item_scope(ui, item, |ui| { + if let Some(item_title) = ItemTitle::from_item(ctx, viewport, ui.style(), item) { + item_title.ui(ctx, ui, item); + } else { + re_log::warn_once!("Failed to create item title for {item:?}"); + return; // WEIRD + } self.item_ui(ctx, viewport, view_states, ui, item, ui_layout); }); @@ -147,12 +149,6 @@ impl SelectionPanel { item: &Item, ui_layout: UiLayout, ) { - if let Some(item_title) = item_tile(ctx, viewport, ui.style(), item) { - item_title.show(ctx, ui, item); - } else { - return; // WEIRD - } - match item { Item::ComponentPath(component_path) => { let entity_path = &component_path.entity_path; @@ -652,232 +648,6 @@ fn space_view_button( item_ui::cursor_interact_with_selectable(ctx, response, item) } -fn item_tile( - ctx: &ViewerContext<'_>, - viewport: &ViewportBlueprint, - style: &egui::Style, - item: &Item, -) -> Option { - match &item { - Item::AppId(app_id) => { - let title = app_id.to_string(); - Some(ItemTitle::new(title).with_icon(&icons::APPLICATION)) - } - - Item::DataSource(data_source) => { - let title = data_source.to_string(); - Some(ItemTitle::new(title).with_icon(&icons::DATA_SOURCE)) - } - - Item::StoreId(store_id) => { - let id_str = format!("{} ID: {}", store_id.kind, store_id); - - let title = if let Some(entity_db) = ctx.store_context.bundle.get(store_id) { - if let Some(info) = entity_db.store_info() { - let time = info - .started - .format_time_custom("[hour]:[minute]:[second]", ctx.app_options.time_zone) - .unwrap_or("".to_owned()); - - format!("{} - {}", info.application_id, time) - } else { - id_str.clone() - } - } else { - id_str.clone() - }; - - let icon = match store_id.kind { - re_log_types::StoreKind::Recording => &icons::RECORDING, - re_log_types::StoreKind::Blueprint => &icons::BLUEPRINT, - }; - - Some(ItemTitle::new(title).with_icon(icon).with_tooltip(id_str)) - } - - Item::Container(container_id) => { - if let Some(container_blueprint) = viewport.container(container_id) { - let hover_text = - if let Some(display_name) = container_blueprint.display_name.as_ref() { - format!( - "{:?} container {display_name:?}", - container_blueprint.container_kind, - ) - } else { - format!("Unnamed {:?} container", container_blueprint.container_kind,) - }; - - let container_name = container_blueprint.display_name_or_default(); - Some( - ItemTitle::new(container_name.as_ref()) - .with_icon(re_viewer_context::icon_for_container_kind( - &container_blueprint.container_kind, - )) - .with_label_style(contents_name_style(&container_name)) - .with_tooltip(hover_text), - ) - } else { - None - } - } - - Item::ComponentPath(component_path) => { - let entity_path = &component_path.entity_path; - let component_name = &component_path.component_name; - - let (_query, db) = guess_query_and_db_for_selected_entity(ctx, entity_path); - let is_static = db - .storage_engine() - .store() - .entity_has_static_component(entity_path, component_name); - - Some( - ItemTitle::new(component_name.short_name()) - .with_icon(if is_static { - &icons::COMPONENT_STATIC - } else { - &icons::COMPONENT_TEMPORAL - }) - .with_tooltip(format!( - "{} component {} of entity '{}'", - if is_static { "Static" } else { "Temporal" }, - component_name.full_name(), - entity_path - )), - ) - } - - Item::SpaceView(view_id) => { - if let Some(view) = viewport.view(view_id) { - let view_class = view.class(ctx.space_view_class_registry); - - let hover_text = if let Some(display_name) = view.display_name.as_ref() { - format!( - "Space view {:?} of type {}", - display_name, - view_class.display_name() - ) - } else { - format!("Unnamed view of type {}", view_class.display_name()) - }; - - let view_name = view.display_name_or_default(); - - Some( - ItemTitle::new(view_name.as_ref()) - .with_icon(view.class(ctx.space_view_class_registry).icon()) - .with_label_style(contents_name_style(&view_name)) - .with_tooltip(hover_text), - ) - } else { - None - } - } - - Item::InstancePath(instance_path) => { - let typ = item.kind(); - let name = instance_path.syntax_highlighted(style); - - Some( - ItemTitle::new(name) - .with_icon(guess_instance_path_icon(ctx, instance_path)) - .with_tooltip(format!("{typ} '{instance_path}'")), - ) - } - - Item::DataResult(view_id, instance_path) => { - let name = instance_path.syntax_highlighted(style); - - if let Some(view) = viewport.view(view_id) { - let typ = item.kind(); - Some( - ItemTitle::new(name) - .with_icon(guess_instance_path_icon(ctx, instance_path)) - .with_tooltip(format!( - "{typ} '{instance_path}' as shown in view {:?}", - view.display_name - )), - ) - } else { - None - } - } - } -} - -#[must_use] -struct ItemTitle { - name: egui::WidgetText, - hover: Option, - icon: Option<&'static re_ui::Icon>, - label_style: Option, -} - -impl ItemTitle { - fn new(name: impl Into) -> Self { - Self { - name: name.into(), - hover: None, - icon: None, - label_style: None, - } - } - - #[inline] - fn with_tooltip(mut self, hover: impl Into) -> Self { - self.hover = Some(hover.into()); - self - } - - #[inline] - fn with_icon(mut self, icon: &'static re_ui::Icon) -> Self { - self.icon = Some(icon); - self - } - - #[inline] - fn with_label_style(mut self, label_style: re_ui::LabelStyle) -> Self { - self.label_style = Some(label_style); - self - } - - fn show(self, ctx: &ViewerContext<'_>, ui: &mut egui::Ui, item: &Item) { - let Self { - name, - hover, - icon, - label_style, - } = self; - - let mut content = list_item::LabelContent::new(name); - - if let Some(icon) = icon { - content = content.with_icon(icon); - } - - if let Some(label_style) = label_style { - content = content.label_style(label_style); - } - - let response = ui - .list_item() - .with_height(DesignTokens::title_bar_height()) - .selected(true) - .show_flat(ui, content); - - if response.clicked() { - // If the user has multiple things selected but only wants to have one thing selected, - // this is how they can do it. - ctx.command_sender - .send_system(re_viewer_context::SystemCommand::SetSelection(item.clone())); - } - - if let Some(hover) = hover { - response.on_hover_text(hover); - } - } -} - /// Display a list of all the views an entity appears in. fn list_existing_data_blueprints( ctx: &ViewerContext<'_>, diff --git a/crates/viewer/re_space_view/src/lib.rs b/crates/viewer/re_space_view/src/lib.rs index e6620167efc8..c7e20d3ba711 100644 --- a/crates/viewer/re_space_view/src/lib.rs +++ b/crates/viewer/re_space_view/src/lib.rs @@ -30,7 +30,9 @@ pub use query::{ pub use results_ext::{ HybridLatestAtResults, HybridResults, HybridResultsChunkIter, RangeResultsExt, }; -pub use view_property_ui::view_property_ui; +pub use view_property_ui::{ + view_property_component_ui, view_property_component_ui_custom, view_property_ui, +}; pub mod external { pub use re_entity_db::external::*; diff --git a/crates/viewer/re_space_view/src/view_property_ui.rs b/crates/viewer/re_space_view/src/view_property_ui.rs index 5136494bc590..f9e174f7487d 100644 --- a/crates/viewer/re_space_view/src/view_property_ui.rs +++ b/crates/viewer/re_space_view/src/view_property_ui.rs @@ -1,14 +1,12 @@ -use re_chunk_store::{external::re_chunk::Arrow2Array, RowId}; use re_types_core::{ - reflection::{ArchetypeFieldReflection, ArchetypeReflection}, - Archetype, ArchetypeName, ArchetypeReflectionMarker, ComponentName, + reflection::ArchetypeFieldReflection, Archetype, ArchetypeReflectionMarker, ComponentName, }; use re_ui::{list_item, UiExt as _}; use re_viewer_context::{ ComponentFallbackProvider, ComponentUiTypes, QueryContext, SpaceViewId, SpaceViewState, ViewerContext, }; -use re_viewport_blueprint::entity_path_for_view_property; +use re_viewport_blueprint::ViewProperty; /// Display the UI for editing all components of a blueprint archetype. /// @@ -20,79 +18,49 @@ pub fn view_property_ui( fallback_provider: &dyn ComponentFallbackProvider, view_state: &dyn SpaceViewState, ) { - let name = A::name(); - if let Some(reflection) = ctx.reflection.archetypes.get(&name) { - view_property_ui_impl( - ctx, - ui, - view_id, - name, - reflection, - view_state, - fallback_provider, - ); - } else { - // The `ArchetypeReflectionMarker` bound should make this impossible. - re_log::warn_once!("Missing reflection data for archetype {name:?}."); - } + let view_property = + ViewProperty::from_archetype::(ctx.blueprint_db(), ctx.blueprint_query, view_id); + view_property_ui_impl(ctx, ui, &view_property, fallback_provider, view_state); } fn view_property_ui_impl( ctx: &ViewerContext<'_>, ui: &mut egui::Ui, - view_id: SpaceViewId, - name: ArchetypeName, - reflection: &ArchetypeReflection, - view_state: &dyn SpaceViewState, + property: &ViewProperty, fallback_provider: &dyn ComponentFallbackProvider, + view_state: &dyn SpaceViewState, ) { - let blueprint_path = entity_path_for_view_property(view_id, ctx.blueprint_db().tree(), name); - let query_ctx = QueryContext { - viewer_ctx: ctx, - target_entity_path: &blueprint_path, - archetype_name: Some(name), - query: ctx.blueprint_query, - view_state, - view_ctx: None, + let Some(reflection) = ctx.reflection.archetypes.get(&property.archetype_name) else { + // The `ArchetypeReflectionMarker` bound should make this impossible. + re_log::warn_once!( + "Missing reflection data for archetype {:?}.", + property.archetype_name + ); + return; }; - let component_results = ctx.blueprint_db().latest_at( - ctx.blueprint_query, - &blueprint_path, - reflection.fields.iter().map(|field| field.component_name), - ); - + let query_ctx = property.query_context(ctx, view_state); // If the property archetype only has a single component, don't show an additional hierarchy level! if reflection.fields.len() == 1 { let field = &reflection.fields[0]; - let component_array = component_results.component_batch_raw(&field.component_name); view_property_component_ui( &query_ctx, ui, - field.component_name, + property, reflection.display_name, field, - &blueprint_path, - component_results.component_row_id(&field.component_name), - component_array.as_deref(), fallback_provider, ); } else { let sub_prop_ui = |ui: &mut egui::Ui| { for field in &reflection.fields { - let display_name = &field.display_name; - - let component_array = component_results.component_batch_raw(&field.component_name); view_property_component_ui( &query_ctx, ui, - field.component_name, - display_name, + property, + field.display_name, field, - &blueprint_path, - component_results.component_row_id(&field.component_name), - component_array.as_deref(), fallback_provider, ); } @@ -102,7 +70,7 @@ fn view_property_ui_impl( .interactive(false) .show_hierarchical_with_children( ui, - ui.make_persistent_id(name.full_name()), + ui.make_persistent_id(property.archetype_name.full_name()), true, list_item::LabelContent::new(reflection.display_name), sub_prop_ui, @@ -111,36 +79,91 @@ fn view_property_ui_impl( } /// Draw view property ui for a single component of a view property archetype. -#[allow(clippy::too_many_arguments)] -fn view_property_component_ui( +/// +/// Use [`view_property_ui`] whenever possible to show the ui for all components of a view property archetype. +/// This function is only useful if you want to show custom ui for some of the components. +pub fn view_property_component_ui( ctx: &QueryContext<'_>, ui: &mut egui::Ui, - component_name: ComponentName, - root_item_display_name: &str, + property: &ViewProperty, + display_name: &str, field: &ArchetypeFieldReflection, - blueprint_path: &re_log_types::EntityPath, - row_id: Option, - component_array: Option<&dyn Arrow2Array>, fallback_provider: &dyn ComponentFallbackProvider, ) { - let singleline_list_item_content = singleline_list_item_content( - ctx, - root_item_display_name, - blueprint_path, - component_name, - row_id, - component_array, - fallback_provider, - ); + let component_array = property.component_raw(field.component_name); + let row_id = property.component_row_id(field.component_name); let ui_types = ctx .viewer_ctx .component_ui_registry - .registered_ui_types(component_name); + .registered_ui_types(field.component_name); + + let singleline_ui: &dyn Fn(&mut egui::Ui) = &|ui| { + ctx.viewer_ctx.component_ui_registry.singleline_edit_ui( + ctx, + ui, + ctx.viewer_ctx.blueprint_db(), + ctx.target_entity_path, + field.component_name, + row_id, + component_array.as_deref(), + fallback_provider, + ); + }; + + let multiline_ui: &dyn Fn(&mut egui::Ui) = &|ui| { + ctx.viewer_ctx.component_ui_registry.multiline_edit_ui( + ctx, + ui, + ctx.viewer_ctx.blueprint_db(), + ctx.target_entity_path, + field.component_name, + row_id, + component_array.as_deref(), + fallback_provider, + ); + }; + // Do this as a separate step to avoid borrowing issues. + let multiline_ui_ref: Option<&dyn Fn(&mut egui::Ui)> = + if ui_types.contains(ComponentUiTypes::MultiLineEditor) { + Some(multiline_ui) + } else { + None + }; - let list_item_response = if ui_types.contains(ComponentUiTypes::MultiLineEditor) { + view_property_component_ui_custom( + ctx, + ui, + property, + display_name, + field, + singleline_ui, + multiline_ui_ref, + ); +} + +/// Draw view property ui for a single component of a view property archetype with custom ui for singleline & multiline. +/// +/// Use [`view_property_ui`] whenever possible to show the ui for all components of a view property archetype. +/// This function is only useful if you want to show custom ui for some of the components. +pub fn view_property_component_ui_custom( + ctx: &QueryContext<'_>, + ui: &mut egui::Ui, + property: &ViewProperty, + display_name: &str, + field: &ArchetypeFieldReflection, + singleline_ui: &dyn Fn(&mut egui::Ui), + multiline_ui: Option<&dyn Fn(&mut egui::Ui)>, +) { + let singleline_list_item_content = list_item::PropertyContent::new(display_name) + .menu_button(&re_ui::icons::MORE, |ui| { + menu_more(ctx.viewer_ctx, ui, property, field.component_name); + }) + .value_fn(move |ui, _| singleline_ui(ui)); + + let list_item_response = if let Some(multiline_ui) = multiline_ui { let default_open = false; - let id = egui::Id::new((blueprint_path.hash(), component_name)); + let id = egui::Id::new((ctx.target_entity_path.hash(), field.component_name)); ui.list_item() .interactive(false) .show_hierarchical_with_children( @@ -149,16 +172,7 @@ fn view_property_component_ui( default_open, singleline_list_item_content, |ui| { - ctx.viewer_ctx.component_ui_registry.multiline_edit_ui( - ctx, - ui, - ctx.viewer_ctx.blueprint_db(), - blueprint_path, - component_name, - row_id, - component_array, - fallback_provider, - ); + multiline_ui(ui); }, ) .item_response @@ -177,14 +191,13 @@ fn view_property_component_ui( fn menu_more( ctx: &ViewerContext<'_>, ui: &mut egui::Ui, - blueprint_path: &re_log_types::EntityPath, + property: &ViewProperty, component_name: ComponentName, - component_array: Option<&dyn Arrow2Array>, ) { + let component_array = property.component_raw(component_name); + let property_differs_from_default = component_array - != ctx - .raw_latest_at_in_default_blueprint(blueprint_path, component_name) - .as_deref(); + != ctx.raw_latest_at_in_default_blueprint(&property.blueprint_store_path, component_name); let response = ui .add_enabled( @@ -199,7 +212,7 @@ If no default blueprint was set or it didn't set any value for this field, this "The property is already set to the same value it has in the default blueprint", ); if response.clicked() { - ctx.reset_blueprint_component_by_name(blueprint_path, component_name); + ctx.reset_blueprint_component_by_name(&property.blueprint_store_path, component_name); ui.close_menu(); } @@ -214,43 +227,10 @@ This has the same effect as not setting the value in the blueprint at all." ) .on_disabled_hover_text("The property is already unset."); if response.clicked() { - ctx.clear_blueprint_component_by_name(blueprint_path, component_name); + ctx.clear_blueprint_component_by_name(&property.blueprint_store_path, component_name); ui.close_menu(); } // TODO(andreas): The next logical thing here is now to save it to the default blueprint! // This should be fairly straight forward except that we need to make sure that a default blueprint exists in the first place. } - -fn singleline_list_item_content<'a>( - ctx: &'a QueryContext<'_>, - display_name: &str, - blueprint_path: &'a re_log_types::EntityPath, - component_name: ComponentName, - row_id: Option, - component_array: Option<&'a dyn Arrow2Array>, - fallback_provider: &'a dyn ComponentFallbackProvider, -) -> list_item::PropertyContent<'a> { - list_item::PropertyContent::new(display_name) - .menu_button(&re_ui::icons::MORE, move |ui| { - menu_more( - ctx.viewer_ctx, - ui, - blueprint_path, - component_name, - component_array, - ); - }) - .value_fn(move |ui, _| { - ctx.viewer_ctx.component_ui_registry.singleline_edit_ui( - ctx, - ui, - ctx.viewer_ctx.blueprint_db(), - blueprint_path, - component_name, - row_id, - component_array, - fallback_provider, - ); - }) -} diff --git a/crates/viewer/re_space_view_graph/Cargo.toml b/crates/viewer/re_space_view_graph/Cargo.toml index 107ebf1da830..136dbbbb2ad3 100644 --- a/crates/viewer/re_space_view_graph/Cargo.toml +++ b/crates/viewer/re_space_view_graph/Cargo.toml @@ -19,6 +19,7 @@ workspace = true all-features = true [dependencies] +re_data_ui.workspace = true re_chunk.workspace = true re_entity_db.workspace = true re_format.workspace = true diff --git a/crates/viewer/re_space_view_graph/src/graph/index.rs b/crates/viewer/re_space_view_graph/src/graph/ids.rs similarity index 71% rename from crates/viewer/re_space_view_graph/src/graph/index.rs rename to crates/viewer/re_space_view_graph/src/graph/ids.rs index 257c7ca389a9..22021c02223e 100644 --- a/crates/viewer/re_space_view_graph/src/graph/index.rs +++ b/crates/viewer/re_space_view_graph/src/graph/ids.rs @@ -34,3 +34,23 @@ impl std::fmt::Debug for NodeId { write!(f, "NodeIndex({:?}@{:?})", self.node_hash, self.entity_hash) } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EdgeId { + // TODO(grtlr): Consider something more storage efficient here + pub source: NodeId, + pub target: NodeId, +} + +impl EdgeId { + pub fn self_edge(node: NodeId) -> Self { + Self { + source: node, + target: node, + } + } + + pub fn is_self_edge(&self) -> bool { + self.source == self.target + } +} diff --git a/crates/viewer/re_space_view_graph/src/graph/mod.rs b/crates/viewer/re_space_view_graph/src/graph/mod.rs index 947216daf86c..4ddb4c969087 100644 --- a/crates/viewer/re_space_view_graph/src/graph/mod.rs +++ b/crates/viewer/re_space_view_graph/src/graph/mod.rs @@ -1,14 +1,21 @@ +//! Provides a basic abstraction over a graph that was logged to an entity. + +// For now this is pretty basic, but in the future we might replace this with +// something like `petgraph`, which will unlock more user interactions, such as +// highlighting of neighboring nodes and edges. + mod hash; use egui::{Pos2, Vec2}; pub(crate) use hash::GraphNodeHash; -mod index; -pub(crate) use index::NodeId; +mod ids; +pub(crate) use ids::{EdgeId, NodeId}; use re_chunk::EntityPath; use re_types::components::{self, GraphType}; use crate::{ + layout::EdgeTemplate, ui::DrawableLabel, visualizers::{EdgeData, NodeData, NodeInstance}, }; @@ -70,16 +77,10 @@ impl Node { } } -pub struct Edge { - pub from: NodeId, - pub to: NodeId, - pub arrow: bool, -} - pub struct Graph { entity: EntityPath, nodes: Vec, - edges: Vec, + edges: Vec, kind: GraphType, } @@ -113,7 +114,7 @@ impl Graph { nodes.push(Node::Implicit { id: edge.source_index, graph_node: edge.source.clone(), - label: DrawableLabel::implicit_circle(), + label: DrawableLabel::implicit_circle(ui), }); seen.insert(edge.source_index); } @@ -121,16 +122,16 @@ impl Graph { nodes.push(Node::Implicit { id: edge.target_index, graph_node: edge.target.clone(), - label: DrawableLabel::implicit_circle(), + label: DrawableLabel::implicit_circle(ui), }); seen.insert(edge.target_index); } } - let es = data.edges.iter().map(|e| Edge { - from: e.source_index, - to: e.target_index, - arrow: data.graph_type == GraphType::Directed, + let es = data.edges.iter().map(|e| EdgeTemplate { + source: e.source_index, + target: e.target_index, + target_arrow: data.graph_type == GraphType::Directed, }); (es.collect(), data.graph_type) @@ -150,7 +151,7 @@ impl Graph { &self.nodes } - pub fn edges(&self) -> &[Edge] { + pub fn edges(&self) -> &[EdgeTemplate] { &self.edges } diff --git a/crates/viewer/re_space_view_graph/src/layout/geometry.rs b/crates/viewer/re_space_view_graph/src/layout/geometry.rs new file mode 100644 index 000000000000..52498c900049 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/layout/geometry.rs @@ -0,0 +1,76 @@ +//! Provides geometric (shape) abstractions for the different elements of a graph layout. + +use egui::{Pos2, Rect, Vec2}; + +#[derive(Clone, Debug)] +pub enum PathGeometry { + /// A simple straight edge. + Line { source: Pos2, target: Pos2 }, + + /// Represents a cubic bezier curve. + /// + /// In the future we could probably support more complex splines. + CubicBezier { + source: Pos2, + target: Pos2, + control: [Pos2; 2], + }, + // We could add other geometries, such as `Orthogonal` here too. +} + +#[derive(Debug)] +pub struct EdgeGeometry { + pub target_arrow: bool, + pub path: PathGeometry, +} + +impl EdgeGeometry { + pub fn bounding_rect(&self) -> Rect { + match self.path { + PathGeometry::Line { source, target } => Rect::from_two_pos(source, target), + // TODO(grtlr): This is just a crude (upper) approximation, as the resulting bounding box can be too large. + // For now this is fine, as there are no interactions on edges yet. + PathGeometry::CubicBezier { + source, + target, + ref control, + } => Rect::from_points(&[&[source, target], control.as_slice()].concat()), + } + } + + /// The starting position of an edge. + pub fn source_pos(&self) -> Pos2 { + match self.path { + PathGeometry::Line { source, .. } | PathGeometry::CubicBezier { source, .. } => source, + } + } + + /// The end position of an edge. + pub fn target_pos(&self) -> Pos2 { + match self.path { + PathGeometry::Line { target, .. } | PathGeometry::CubicBezier { target, .. } => target, + } + } + + /// The direction of the edge at the source node (normalized). + pub fn source_arrow_direction(&self) -> Vec2 { + use PathGeometry::{CubicBezier, Line}; + match self.path { + Line { source, target } => (source.to_vec2() - target.to_vec2()).normalized(), + CubicBezier { + source, control, .. + } => (control[0].to_vec2() - source.to_vec2()).normalized(), + } + } + + /// The direction of the edge at the target node (normalized). + pub fn target_arrow_direction(&self) -> Vec2 { + use PathGeometry::{CubicBezier, Line}; + match self.path { + Line { source, target } => (target.to_vec2() - source.to_vec2()).normalized(), + CubicBezier { + target, control, .. + } => (target.to_vec2() - control[1].to_vec2()).normalized(), + } + } +} diff --git a/crates/viewer/re_space_view_graph/src/layout/mod.rs b/crates/viewer/re_space_view_graph/src/layout/mod.rs index 8c280d81c782..a06d3748be2f 100644 --- a/crates/viewer/re_space_view_graph/src/layout/mod.rs +++ b/crates/viewer/re_space_view_graph/src/layout/mod.rs @@ -1,7 +1,10 @@ +mod geometry; mod provider; mod request; mod result; +mod slots; +pub use geometry::{EdgeGeometry, PathGeometry}; pub use provider::ForceLayoutProvider; -pub use request::LayoutRequest; +pub use request::{EdgeTemplate, LayoutRequest}; pub use result::Layout; diff --git a/crates/viewer/re_space_view_graph/src/layout/provider.rs b/crates/viewer/re_space_view_graph/src/layout/provider.rs index d3f845f45457..1036a2b20797 100644 --- a/crates/viewer/re_space_view_graph/src/layout/provider.rs +++ b/crates/viewer/re_space_view_graph/src/layout/provider.rs @@ -1,9 +1,20 @@ +//! Performs the layout of the graph, i.e. converting an [`LayoutRequest`] into a [`Layout`]. + +// For now we have only a single layout provider that is based on a force-directed model. +// In the future, this could be expanded to support different (specialized layout alorithms). +// Low-hanging fruit would be tree-based layouts. But we could also think about more complex +// layouts, such as `dot` from `graphviz`. + use egui::{Pos2, Rect, Vec2}; -use fjadra as fj; +use fjadra::{self as fj}; -use crate::graph::NodeId; +use crate::graph::{EdgeId, NodeId}; -use super::{request::NodeTemplate, Layout, LayoutRequest}; +use super::{ + request::NodeTemplate, + slots::{slotted_edges, Slot, SlotKind}, + EdgeGeometry, EdgeTemplate, Layout, LayoutRequest, PathGeometry, +}; impl<'a> From<&'a NodeTemplate> for fj::Node { fn from(node: &'a NodeTemplate) -> Self { @@ -17,16 +28,31 @@ impl<'a> From<&'a NodeTemplate> for fj::Node { pub struct ForceLayoutProvider { simulation: fj::Simulation, node_index: ahash::HashMap, - edges: Vec<(NodeId, NodeId)>, + pub request: LayoutRequest, } impl ForceLayoutProvider { - pub fn new(request: &LayoutRequest) -> Self { + pub fn new(request: LayoutRequest) -> Self { + Self::new_impl(request, None) + } + + pub fn new_with_previous(request: LayoutRequest, layout: &Layout) -> Self { + Self::new_impl(request, Some(layout)) + } + + // TODO(grtlr): Consider consuming the old layout to avoid re-allocating the extents. + // That logic has to be revised when adding the blueprints anyways. + fn new_impl(request: LayoutRequest, layout: Option<&Layout>) -> Self { let nodes = request.graphs.iter().flat_map(|(_, graph_template)| { - graph_template - .nodes - .iter() - .map(|n| (n.0, fj::Node::from(n.1))) + graph_template.nodes.iter().map(|n| { + let mut fj_node = fj::Node::from(n.1); + if let Some(rect) = layout.and_then(|l| l.get_node(n.0)) { + let pos = rect.center(); + fj_node = fj_node.position(pos.x as f64, pos.y as f64); + } + + (n.0, fj_node) + }) }); let mut node_index = ahash::HashMap::default(); @@ -43,9 +69,11 @@ impl ForceLayoutProvider { .iter() .flat_map(|(_, graph_template)| graph_template.edges.iter()); - let all_edges = all_edges_iter + // Looking at self-edges does not make sense in a force-based layout, so we filter those out. + let considered_edges = all_edges_iter .clone() - .map(|(a, b)| (node_index[a], node_index[b])); + .filter(|(id, _)| !id.is_self_edge()) + .map(|(id, _)| (node_index[&id.source], node_index[&id.target])); // TODO(grtlr): Currently we guesstimate good forces. Eventually these should be exposed as blueprints. let simulation = fj::SimulationBuilder::default() @@ -53,7 +81,7 @@ impl ForceLayoutProvider { .build(all_nodes) .add_force( "link", - fj::Link::new(all_edges).distance(50.0).iterations(2), + fj::Link::new(considered_edges).distance(50.0).iterations(2), ) .add_force("charge", fj::ManyBody::new()) // TODO(grtlr): This is a small stop-gap until we have blueprints to prevent nodes from flying away. @@ -63,15 +91,15 @@ impl ForceLayoutProvider { Self { simulation, node_index, - edges: all_edges_iter.copied().collect(), + request, } } - pub fn init(&self, request: &LayoutRequest) -> Layout { + pub fn init(&self) -> Layout { let positions = self.simulation.positions().collect::>(); let mut extents = ahash::HashMap::default(); - for graph in request.graphs.values() { + for graph in self.request.graphs.values() { for (id, node) in &graph.nodes { let i = self.node_index[id]; let [x, y] = positions[i]; @@ -84,6 +112,7 @@ impl ForceLayoutProvider { nodes: extents, // Without any real node positions, we probably don't want to draw edges either. edges: ahash::HashMap::default(), + entities: Vec::new(), } } @@ -93,18 +122,130 @@ impl ForceLayoutProvider { let positions = self.simulation.positions().collect::>(); - for (node, extent) in &mut layout.nodes { - let i = self.node_index[node]; - let [x, y] = positions[i]; - let pos = Pos2::new(x as f32, y as f32); - extent.set_center(pos); - } + // We clear all unnecessary data from the previous layout, but keep its space allocated. + layout.entities.clear(); + layout.edges.clear(); + + for (entity, graph) in &self.request.graphs { + let mut current_rect = Rect::NOTHING; - for (from, to) in &self.edges { - layout.edges.insert( - (*from, *to), - line_segment(layout.nodes[from], layout.nodes[to]), - ); + for node in graph.nodes.keys() { + let extent = layout.nodes.get_mut(node).expect("node has to be present"); + let i = self.node_index[node]; + let [x, y] = positions[i]; + let pos = Pos2::new(x as f32, y as f32); + extent.set_center(pos); + current_rect = current_rect.union(*extent); + } + + layout.entities.push((entity.clone(), current_rect)); + + // Multiple edges can occupy the same space in the layout. + for Slot { kind, edges } in + slotted_edges(graph.edges.values().flat_map(|ts| ts.iter())).values() + { + match kind { + SlotKind::SelfEdge { node } => { + let rect = layout.nodes[node]; + let id = EdgeId::self_edge(*node); + let geometries = layout.edges.entry(id).or_default(); + geometries.extend(layout_self_edges(rect, edges)); + } + SlotKind::Regular { + source: slot_source, + target: slot_target, + } => { + if let &[edge] = edges.as_slice() { + // A single regular straight edge. + let target_arrow = edge.target_arrow; + let geometries = layout + .edges + .entry(EdgeId { + source: edge.source, + target: edge.target, + }) + .or_default(); + geometries.push(EdgeGeometry { + target_arrow, + path: line_segment( + layout.nodes[&edge.source], + layout.nodes[&edge.target], + ), + }); + } else { + // Multiple edges occupy the same space, so we fan them out. + let num_edges = edges.len(); + + // Controls the amount of space (in scene coordinates) that a slot can occupy. + let fan_amount = 20.0; + + for (i, edge) in edges.iter().enumerate() { + let source_rect = layout.nodes[slot_source]; + let target_rect = layout.nodes[slot_target]; + + let d = (target_rect.center() - source_rect.center()).normalized(); + + let source_pos = source_rect.intersects_ray_from_center(d); + let target_pos = target_rect.intersects_ray_from_center(-d); + + // How far along the edge should the control points be? + let c1_base = source_pos + (target_pos - source_pos) * 0.25; + let c2_base = source_pos + (target_pos - source_pos) * 0.75; + + let c1_base_n = Vec2::new(-c1_base.y, c1_base.x).normalized(); + let mut c2_base_n = Vec2::new(-c2_base.y, c2_base.x).normalized(); + + // Make sure both point to the same side of the edge. + if c1_base_n.dot(c2_base_n) < 0.0 { + // If they point in opposite directions, flip one of them. + c2_base_n = -c2_base_n; + } + + let c1_left = c1_base + c1_base_n * (fan_amount / 2.); + let c2_left = c2_base + c2_base_n * (fan_amount / 2.); + + let c1_right = c1_base - c1_base_n * (fan_amount / 2.); + let c2_right = c2_base - c2_base_n * (fan_amount / 2.); + + // Calculate an offset for the control points based on index `i`, spreading points equidistantly. + let t = (i as f32) / (num_edges - 1) as f32; + + // Compute control points, `c1` and `c2`, based on the offset + let c1 = c1_right + (c1_left - c1_right) * t; + let c2 = c2_right + (c2_left - c2_right) * t; + + let geometries = layout + .edges + .entry(EdgeId { + source: edge.source, + target: edge.target, + }) + .or_default(); + + // We potentially need to restore the direction of the edge, after we have used it's canonical form earlier. + let path = if edge.source == *slot_source { + PathGeometry::CubicBezier { + source: source_pos, + target: target_pos, + control: [c1, c2], + } + } else { + PathGeometry::CubicBezier { + source: target_pos, + target: source_pos, + control: [c2, c1], + } + }; + + geometries.push(EdgeGeometry { + target_arrow: edge.target_arrow, + path, + }); + } + } + } + } + } } self.simulation.finished() @@ -112,7 +253,7 @@ impl ForceLayoutProvider { } /// Helper function to calculate the line segment between two rectangles. -fn line_segment(source: Rect, target: Rect) -> [Pos2; 2] { +fn line_segment(source: Rect, target: Rect) -> PathGeometry { let source_center = source.center(); let target_center = target.center(); @@ -120,89 +261,36 @@ fn line_segment(source: Rect, target: Rect) -> [Pos2; 2] { let direction = (target_center - source_center).normalized(); // Find the border points on both rectangles - let source_point = intersects_ray_from_center(source, direction); - let target_point = intersects_ray_from_center(target, -direction); // Reverse direction for target - - [source_point, target_point] -} - -/// Helper function to find the point where the line intersects the border of a rectangle -fn intersects_ray_from_center(rect: Rect, direction: Vec2) -> Pos2 { - let mut tmin = f32::NEG_INFINITY; - let mut tmax = f32::INFINITY; - - for i in 0..2 { - let inv_d = 1.0 / -direction[i]; - let mut t0 = (rect.min[i] - rect.center()[i]) * inv_d; - let mut t1 = (rect.max[i] - rect.center()[i]) * inv_d; + let source_point = source.intersects_ray_from_center(direction); + let target_point = target.intersects_ray_from_center(-direction); // Reverse direction for target - if inv_d < 0.0 { - std::mem::swap(&mut t0, &mut t1); - } - - tmin = tmin.max(t0); - tmax = tmax.min(t1); + PathGeometry::Line { + source: source_point, + target: target_point, } - - let t = tmax.min(tmin); // Pick the first intersection - rect.center() + t * -direction } -#[cfg(test)] -mod test { - use super::*; - use egui::pos2; - - #[test] - fn test_ray_intersection() { - let rect = Rect::from_min_max(pos2(1.0, 1.0), pos2(3.0, 3.0)); - - assert_eq!( - intersects_ray_from_center(rect, Vec2::RIGHT), - pos2(3.0, 2.0), - "rightward ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, Vec2::UP), - pos2(2.0, 1.0), - "upward ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, Vec2::LEFT), - pos2(1.0, 2.0), - "leftward ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, Vec2::DOWN), - pos2(2.0, 3.0), - "downward ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, (Vec2::LEFT + Vec2::DOWN).normalized()), - pos2(1.0, 3.0), - "bottom-left corner ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, (Vec2::LEFT + Vec2::UP).normalized()), - pos2(1.0, 1.0), - "top-left corner ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, (Vec2::RIGHT + Vec2::DOWN).normalized()), - pos2(3.0, 3.0), - "bottom-right corner ray" - ); - - assert_eq!( - intersects_ray_from_center(rect, (Vec2::RIGHT + Vec2::UP).normalized()), - pos2(3.0, 1.0), - "top-right corner ray" - ); - } +fn layout_self_edges<'a>( + rect: Rect, + edges: &'a [&EdgeTemplate], +) -> impl Iterator + 'a { + edges.iter().enumerate().map(move |(i, edge)| { + let offset = (i + 1) as f32; + let target_arrow = edge.target_arrow; + let anchor = rect.center_top(); + + EdgeGeometry { + target_arrow, + path: PathGeometry::CubicBezier { + // TODO(grtlr): We could probably consider the actual node size here. + source: anchor + Vec2::LEFT * 4., + target: anchor + Vec2::RIGHT * 4., + // TODO(grtlr): The actual length of that spline should follow the `distance` parameter of the link force. + control: [ + anchor + Vec2::new(-30. * offset, -40. * offset), + anchor + Vec2::new(30. * offset, -40. * offset), + ], + }, + } + }) } diff --git a/crates/viewer/re_space_view_graph/src/layout/request.rs b/crates/viewer/re_space_view_graph/src/layout/request.rs index be17a02e4395..177d05fbec15 100644 --- a/crates/viewer/re_space_view_graph/src/layout/request.rs +++ b/crates/viewer/re_space_view_graph/src/layout/request.rs @@ -1,9 +1,17 @@ -use std::collections::{BTreeMap, BTreeSet}; +//! Contains all the (geometric) information that is considered when performing a graph layout. +//! +//! We support: +//! * Multiple edges between the same two nodes. +//! * Self-edges +//! +//!
Duplicated graph nodes are undefined behavior.
+ +use std::collections::BTreeMap; use egui::{Pos2, Vec2}; use re_chunk::EntityPath; -use crate::graph::{Graph, NodeId}; +use crate::graph::{EdgeId, Graph, NodeId}; #[derive(PartialEq)] pub(super) struct NodeTemplate { @@ -11,10 +19,21 @@ pub(super) struct NodeTemplate { pub(super) fixed_position: Option, } +#[derive(Clone, PartialEq, Eq)] +pub struct EdgeTemplate { + pub source: NodeId, + pub target: NodeId, + pub target_arrow: bool, +} + #[derive(Default, PartialEq)] pub(super) struct GraphTemplate { pub(super) nodes: BTreeMap, - pub(super) edges: BTreeSet<(NodeId, NodeId)>, + + /// The edges in the layout. + /// + /// Each entry can contain multiple edges. + pub(super) edges: BTreeMap>, } /// A [`LayoutRequest`] encapsulates all the information that is considered when computing a layout. @@ -39,11 +58,21 @@ impl LayoutRequest { size: node.size(), fixed_position: node.position(), }; - entity.nodes.insert(node.id(), shape); + let duplicate = entity.nodes.insert(node.id(), shape); + debug_assert!( + duplicate.is_none(), + "duplicated nodes are undefined behavior" + ); } for edge in graph.edges() { - entity.edges.insert((edge.from, edge.to)); + let id = EdgeId { + source: edge.source, + target: edge.target, + }; + + let es = entity.edges.entry(id).or_default(); + es.push(edge.clone()); } } diff --git a/crates/viewer/re_space_view_graph/src/layout/result.rs b/crates/viewer/re_space_view_graph/src/layout/result.rs index 78734514ee13..31a9219b909c 100644 --- a/crates/viewer/re_space_view_graph/src/layout/result.rs +++ b/crates/viewer/re_space_view_graph/src/layout/result.rs @@ -1,14 +1,17 @@ -use egui::{Pos2, Rect}; +//! Defines the output of a layout algorithm, i.e. everything that we need to render the graph. -use crate::graph::NodeId; +use egui::Rect; +use re_chunk::EntityPath; -pub type LineSegment = [Pos2; 2]; +use crate::graph::{EdgeId, NodeId}; -#[derive(Debug, PartialEq, Eq)] +use super::EdgeGeometry; + +#[derive(Debug)] pub struct Layout { pub(super) nodes: ahash::HashMap, - pub(super) edges: ahash::HashMap<(NodeId, NodeId), LineSegment>, - // TODO(grtlr): Consider adding the entity rects here too. + pub(super) edges: ahash::HashMap>, + pub(super) entities: Vec<(EntityPath, Rect)>, } fn bounding_rect_from_iter(rectangles: impl Iterator) -> egui::Rect { @@ -29,7 +32,22 @@ impl Layout { } /// Gets the shape of an edge in the final layout. - pub fn get_edge(&self, from: NodeId, to: NodeId) -> Option { - self.edges.get(&(from, to)).copied() + pub fn get_edge(&self, edge: &EdgeId) -> Option<&[EdgeGeometry]> { + self.edges.get(edge).map(|es| es.as_slice()) + } + + /// Returns an iterator over all edges in the layout. + pub fn edges(&self) -> impl Iterator { + self.edges.iter().map(|(id, es)| (*id, es.as_slice())) + } + + /// Returns the number of entities in the layout. + pub fn num_entities(&self) -> usize { + self.entities.len() + } + + /// Returns an iterator over all edges in the layout. + pub fn entities(&self) -> impl Iterator { + self.entities.iter() } } diff --git a/crates/viewer/re_space_view_graph/src/layout/slots.rs b/crates/viewer/re_space_view_graph/src/layout/slots.rs new file mode 100644 index 000000000000..02dc4f05b2a4 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/layout/slots.rs @@ -0,0 +1,63 @@ +//! Force-directed graph layouts assume edges to be straight lines. A [`Slot`] +//! represents the space that a single edge, _or multiple_ edges can occupy between two nodes. +//! +//! We achieve this by bringing edges into a canonical form via [`SlotId`], which +//! we can then use to find duplicates. + +use crate::graph::NodeId; + +use super::request::EdgeTemplate; + +/// Uniquely identifies a [`Slot`] by ordering the [`NodeIds`](NodeId) that make up an edge. +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +pub struct SlotId(NodeId, NodeId); + +impl SlotId { + pub fn new(source: NodeId, target: NodeId) -> Self { + if source < target { + Self(source, target) + } else { + Self(target, source) + } + } +} + +/// There are different types of [`Slots`](Slot) that are laid out differently. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SlotKind { + /// An edge slot going from `source` to `target`. Source and target represent the canonical order of the slot, as specified by [`SlotId`] + Regular { source: NodeId, target: NodeId }, + + /// An edge where `source == target`. + SelfEdge { node: NodeId }, +} + +pub struct Slot<'a> { + pub kind: SlotKind, + pub edges: Vec<&'a EdgeTemplate>, +} + +/// Converts a list of edges into their slotted form. +pub fn slotted_edges<'a>( + edges: impl Iterator, +) -> ahash::HashMap> { + let mut slots: ahash::HashMap> = ahash::HashMap::default(); + + for e in edges { + let id = SlotId::new(e.source, e.target); + let slot = slots.entry(id).or_insert_with_key(|id| Slot { + kind: match e.source == e.target { + true => SlotKind::SelfEdge { node: e.source }, + false => SlotKind::Regular { + source: id.0, + target: id.1, + }, + }, + edges: Vec::new(), + }); + + slot.edges.push(e); + } + + slots +} diff --git a/crates/viewer/re_space_view_graph/src/ui/draw.rs b/crates/viewer/re_space_view_graph/src/ui/draw.rs index ff094f1c0045..21ab773bafa0 100644 --- a/crates/viewer/re_space_view_graph/src/ui/draw.rs +++ b/crates/viewer/re_space_view_graph/src/ui/draw.rs @@ -1,31 +1,25 @@ use std::sync::Arc; use egui::{ - Align2, Color32, FontId, FontSelection, Frame, Galley, Painter, Pos2, Rect, Response, RichText, - Sense, Shape, Stroke, TextWrapMode, Ui, UiBuilder, Vec2, WidgetText, + epaint::CubicBezierShape, Align2, Color32, FontId, FontSelection, Frame, Galley, Painter, Pos2, + Rect, Response, RichText, Sense, Shape, Stroke, TextWrapMode, Ui, UiBuilder, Vec2, WidgetText, }; use re_chunk::EntityPath; +use re_data_ui::{item_ui, DataUi as _}; use re_entity_db::InstancePath; use re_types::ArrowString; +use re_ui::list_item; use re_viewer_context::{ - HoverHighlight, InteractionHighlight, Item, SelectionHighlight, SpaceViewHighlights, ViewQuery, - ViewerContext, + HoverHighlight, InteractionHighlight, Item, SelectionHighlight, SpaceViewHighlights, UiLayout, + ViewQuery, ViewerContext, }; use crate::{ graph::{Graph, Node}, - layout::Layout, + layout::{EdgeGeometry, Layout, PathGeometry}, visualizers::Label, }; -// Sorry for the pun, could not resist 😎. -// On a serious note, is there no other way to create a `Sense` that does nothing? -const NON_SENSE: Sense = Sense { - click: false, - drag: false, - focusable: false, -}; - pub enum DrawableLabel { Circle(CircleLabel), Text(TextLabel), @@ -64,10 +58,10 @@ impl DrawableLabel { Self::Circle(CircleLabel { radius, color }) } - pub fn implicit_circle() -> Self { + pub fn implicit_circle(ui: &Ui) -> Self { Self::Circle(CircleLabel { radius: 4.0, - color: None, + color: Some(ui.style().visuals.weak_text_color()), }) } @@ -132,7 +126,7 @@ fn draw_text_label(ui: &mut Ui, label: &TextLabel, highlight: InteractionHighlig .sense(Sense::click()), ) }) - .inner + .response } /// Draws a node at the given position. @@ -192,24 +186,43 @@ fn draw_arrow(painter: &Painter, tip: Pos2, direction: Vec2, color: Color32) { } /// Draws an edge between two points, optionally with an arrow at the target point. -fn draw_edge(ui: &mut Ui, points: [Pos2; 2], show_arrow: bool) -> Response { +pub fn draw_edge(ui: &mut Ui, geometry: &EdgeGeometry, show_arrow: bool) -> Response { let fg = ui.style().visuals.text_color(); + let stroke = Stroke::new(1.0, fg); - let rect = Rect::from_points(&points); let painter = ui.painter(); - painter.line_segment(points, Stroke::new(1.0, fg)); - // Calculate direction vector from source to target - let direction = (points[1] - points[0]).normalized(); + match geometry.path { + PathGeometry::Line { source, target } => { + painter.line_segment([source, target], stroke); + } + PathGeometry::CubicBezier { + source, + target, + control, + } => { + painter.add(CubicBezierShape { + points: [source, control[0], control[1], target], + closed: false, + fill: Color32::TRANSPARENT, + stroke: stroke.into(), + }); + } + } // Conditionally draw an arrow at the target point if show_arrow { - draw_arrow(painter, points[1], direction, fg); + draw_arrow( + painter, + geometry.target_pos(), + geometry.target_arrow_direction(), + stroke.color, + ); } // We can add interactions in the future, for now we simply allocate the // rect, so that bounding boxes are computed correctly. - ui.allocate_rect(rect, NON_SENSE) + ui.allocate_rect(geometry.bounding_rect(), Sense::hover()) } pub fn draw_entity_rect( @@ -251,7 +264,6 @@ pub fn draw_entity_rect( } /// Draws the graph using the layout. -#[must_use] pub fn draw_graph( ui: &mut Ui, ctx: &ViewerContext<'_>, @@ -271,41 +283,62 @@ pub fn draw_graph( let response = match node { Node::Explicit { instance, .. } => { let highlight = entity_highlights.index_highlight(instance.instance_index); - let response = draw_node(ui, center, node.label(), highlight); - - let response = if let Label::Text { text, .. } = &instance.label { - response.on_hover_text(format!( - "Graph Node: {}\nLabel: {text}", - instance.graph_node.as_str(), - )) - } else { - response.on_hover_text(format!("Graph Node: {}", instance.graph_node.as_str(),)) - }; + let mut response = draw_node(ui, center, node.label(), highlight); let instance_path = InstancePath::instance(entity_path.clone(), instance.instance_index); ctx.select_hovered_on_click( &response, - vec![(Item::DataResult(query.space_view_id, instance_path), None)].into_iter(), + Item::DataResult(query.space_view_id, instance_path.clone()), ); + response = response.on_hover_ui_at_pointer(|ui| { + list_item::list_item_scope(ui, "graph_node_hover", |ui| { + item_ui::instance_path_button( + ctx, + &query.latest_at_query(), + ctx.recording(), + ui, + Some(query.space_view_id), + &instance_path, + ); + + instance_path.data_ui_recording(ctx, ui, UiLayout::Tooltip); + }); + }); + response } Node::Implicit { graph_node, .. } => { - draw_node(ui, center, node.label(), Default::default()) - .on_hover_text(format!("Implicit Graph Node: {}", graph_node.as_str(),)) + draw_node(ui, center, node.label(), Default::default()).on_hover_text(format!( + "Implicit node {} created via a reference in a GraphEdge component", + graph_node.as_str(), + )) } }; current_rect = current_rect.union(response.rect); } - for edge in graph.edges() { - let points = layout - .get_edge(edge.from, edge.to) - .unwrap_or([Pos2::ZERO, Pos2::ZERO]); - let resp = draw_edge(ui, points, edge.arrow); - current_rect = current_rect.union(resp.rect); + for (_, geometries) in layout.edges() { + for geometry in geometries { + let response = draw_edge(ui, geometry, geometry.target_arrow); + current_rect = current_rect.union(response.rect); + } + } + + // We only show entity rects if there are multiple entities. + // For now, these entity rects are not part of the layout, but rather tracked on the fly. + if layout.num_entities() > 1 { + for (entity_path, rect) in layout.entities() { + let resp = draw_entity_rect(ui, *rect, entity_path, &query.highlights); + current_rect = current_rect.union(resp.rect); + let instance_path = InstancePath::entity_all(entity_path.clone()); + ctx.select_hovered_on_click( + &resp, + vec![(Item::DataResult(query.space_view_id, instance_path), None)].into_iter(), + ); + } } current_rect diff --git a/crates/viewer/re_space_view_graph/src/ui/mod.rs b/crates/viewer/re_space_view_graph/src/ui/mod.rs index e387d8568276..57c7974e5b72 100644 --- a/crates/viewer/re_space_view_graph/src/ui/mod.rs +++ b/crates/viewer/re_space_view_graph/src/ui/mod.rs @@ -1,5 +1,5 @@ mod draw; mod state; -pub use draw::{draw_debug, draw_entity_rect, draw_graph, DrawableLabel}; +pub use draw::{draw_debug, draw_graph, DrawableLabel}; pub use state::GraphSpaceViewState; diff --git a/crates/viewer/re_space_view_graph/src/ui/state.rs b/crates/viewer/re_space_view_graph/src/ui/state.rs index ccf77707ebf4..749e3720f937 100644 --- a/crates/viewer/re_space_view_graph/src/ui/state.rs +++ b/crates/viewer/re_space_view_graph/src/ui/state.rs @@ -65,14 +65,12 @@ pub enum LayoutState { #[default] None, InProgress { - request: LayoutRequest, layout: Layout, provider: ForceLayoutProvider, }, Finished { - request: LayoutRequest, layout: Layout, - _provider: ForceLayoutProvider, + provider: ForceLayoutProvider, }, } @@ -102,46 +100,39 @@ impl LayoutState { fn update(self, new_request: LayoutRequest) -> Self { match self { // Layout is up to date, nothing to do here. - Self::Finished { ref request, .. } if request == &new_request => { + Self::Finished { ref provider, .. } if provider.request == new_request => { self // no op } // We need to recompute the layout. - Self::None | Self::Finished { .. } => { - let provider = ForceLayoutProvider::new(&new_request); - let layout = provider.init(&new_request); - - Self::InProgress { - request: new_request, - layout, - provider, - } + Self::None => { + let provider = ForceLayoutProvider::new(new_request); + let layout = provider.init(); + + Self::InProgress { layout, provider } + } + Self::Finished { layout, .. } => { + let mut provider = ForceLayoutProvider::new_with_previous(new_request, &layout); + let mut layout = provider.init(); + provider.tick(&mut layout); + + Self::InProgress { layout, provider } } - Self::InProgress { request, .. } if request != new_request => { - let provider = ForceLayoutProvider::new(&new_request); - let layout = provider.init(&new_request); - - Self::InProgress { - request: new_request, - layout, - provider, - } + Self::InProgress { + layout, provider, .. + } if provider.request != new_request => { + let mut provider = ForceLayoutProvider::new_with_previous(new_request, &layout); + let mut layout = provider.init(); + provider.tick(&mut layout); + + Self::InProgress { layout, provider } } // We keep iterating on the layout until it is stable. Self::InProgress { - request, mut layout, mut provider, } => match provider.tick(&mut layout) { - true => Self::Finished { - request, - layout, - _provider: provider, - }, - false => Self::InProgress { - request, - layout, - provider, - }, + true => Self::Finished { layout, provider }, + false => Self::InProgress { layout, provider }, }, } } diff --git a/crates/viewer/re_space_view_graph/src/view.rs b/crates/viewer/re_space_view_graph/src/view.rs index cef909294e39..6539892a1447 100644 --- a/crates/viewer/re_space_view_graph/src/view.rs +++ b/crates/viewer/re_space_view_graph/src/view.rs @@ -1,4 +1,3 @@ -use re_entity_db::InstancePath; use re_log_types::EntityPath; use re_space_view::{ controls::{DRAG_PAN2D_BUTTON, ZOOM_SCROLL_MODIFIER}, @@ -14,18 +13,17 @@ use re_ui::{ ModifiersMarkdown, MouseButtonMarkdown, UiExt as _, }; use re_viewer_context::{ - IdentifiedViewSystem as _, Item, RecommendedSpaceView, SpaceViewClass, - SpaceViewClassLayoutPriority, SpaceViewClassRegistryError, SpaceViewId, - SpaceViewSpawnHeuristics, SpaceViewState, SpaceViewStateExt as _, - SpaceViewSystemExecutionError, SpaceViewSystemRegistrator, SystemExecutionOutput, ViewQuery, - ViewerContext, + IdentifiedViewSystem as _, RecommendedSpaceView, SpaceViewClass, SpaceViewClassLayoutPriority, + SpaceViewClassRegistryError, SpaceViewId, SpaceViewSpawnHeuristics, SpaceViewState, + SpaceViewStateExt as _, SpaceViewSystemExecutionError, SpaceViewSystemRegistrator, + SystemExecutionOutput, ViewQuery, ViewerContext, }; use re_viewport_blueprint::ViewProperty; use crate::{ graph::Graph, layout::LayoutRequest, - ui::{draw_debug, draw_entity_rect, draw_graph, GraphSpaceViewState}, + ui::{draw_debug, draw_graph, GraphSpaceViewState}, visualizers::{merge, EdgesVisualizer, NodeVisualizer}, }; @@ -178,24 +176,8 @@ Display a graph of nodes and edges. let mut world_bounding_rect = egui::Rect::NOTHING; for graph in &graphs { - let mut current_rect = draw_graph(ui, ctx, graph, layout, query); - - // We only show entity rects if there are multiple entities. - // For now, these entity rects are not part of the layout, but rather tracked on the fly. - if graphs.len() > 1 { - let resp = - draw_entity_rect(ui, current_rect, graph.entity(), &query.highlights); - - let instance_path = InstancePath::entity_all(graph.entity().clone()); - ctx.select_hovered_on_click( - &resp, - vec![(Item::DataResult(query.space_view_id, instance_path), None)] - .into_iter(), - ); - current_rect = current_rect.union(resp.rect); - } - - world_bounding_rect = world_bounding_rect.union(current_rect); + let graph_rect = draw_graph(ui, ctx, graph, layout, query); + world_bounding_rect = world_bounding_rect.union(graph_rect); } // We need to draw the debug information after the rest to ensure that we have the correct bounding box. diff --git a/crates/viewer/re_space_view_graph/src/visualizers/edges.rs b/crates/viewer/re_space_view_graph/src/visualizers/edges.rs index 2753ad20d3cc..ac7a45512229 100644 --- a/crates/viewer/re_space_view_graph/src/visualizers/edges.rs +++ b/crates/viewer/re_space_view_graph/src/visualizers/edges.rs @@ -1,5 +1,5 @@ use re_chunk::LatestAtQuery; -use re_log_types::EntityPath; +use re_log_types::{EntityPath, Instance}; use re_space_view::{DataResultQuery, RangeResultsExt}; use re_types::{ self, archetypes, @@ -19,30 +19,14 @@ pub struct EdgesVisualizer { } pub struct EdgeInstance { + // We will need this in the future, when we want to select individual edges. + pub _instance: Instance, pub source: GraphNode, pub target: GraphNode, pub source_index: NodeId, pub target_index: NodeId, } -impl std::hash::Hash for EdgeInstance { - fn hash(&self, state: &mut H) { - // We use the more verbose destructring here, to make sure that we - // exhaustively consider all fields when hashing (we get a compiler - // warning when we forget a field). - let Self { - // The index fields already uniquely identify `source` and `target`. - source: _, - target: _, - source_index, - target_index, - } = self; - source_index.hash(state); - target_index.hash(state); - } -} - -#[derive(Hash)] pub struct EdgeData { pub graph_type: components::GraphType, pub edges: Vec, @@ -81,7 +65,8 @@ impl VisualizerSystem for EdgesVisualizer { for (_index, edges) in all_indexed_edges.component::() { let edges = edges .iter() - .map(|edge| { + .enumerate() + .map(|(i, edge)| { let source = GraphNode::from(edge.first.clone()); let target = GraphNode::from(edge.second.clone()); @@ -90,6 +75,7 @@ impl VisualizerSystem for EdgesVisualizer { let target_index = NodeId::from_entity_node(entity_path, &target); EdgeInstance { + _instance: Instance::from(i as u64), source, target, source_index, diff --git a/crates/viewer/re_viewer/Cargo.toml b/crates/viewer/re_viewer/Cargo.toml index c6783b229fb0..242e8d6a4c65 100644 --- a/crates/viewer/re_viewer/Cargo.toml +++ b/crates/viewer/re_viewer/Cargo.toml @@ -32,7 +32,7 @@ crate-type = ["cdylib", "rlib"] [features] -default = ["analytics"] +default = ["analytics", "map_view"] ## Enable telemetry using our analytics SDK. analytics = ["dep:re_analytics"] diff --git a/crates/viewer/re_viewer_context/src/item.rs b/crates/viewer/re_viewer_context/src/item.rs index 951e74ec57c4..ddda0c537fbb 100644 --- a/crates/viewer/re_viewer_context/src/item.rs +++ b/crates/viewer/re_viewer_context/src/item.rs @@ -17,20 +17,20 @@ pub enum Item { /// A recording (or blueprint) StoreId(re_log_types::StoreId), + /// An entity or instance from the chunk store. + InstancePath(InstancePath), + /// A component of an entity from the chunk store. ComponentPath(ComponentPath), - /// A space view. - SpaceView(SpaceViewId), + /// A viewport container. + Container(ContainerId), - /// An entity or instance from the chunk store. - InstancePath(InstancePath), + /// A viwport space view. + SpaceView(SpaceViewId), /// An entity or instance in the context of a space view's data results. DataResult(SpaceViewId, InstancePath), - - /// A container. - Container(ContainerId), } impl Item { diff --git a/crates/viewer/re_viewer_context/src/space_view/space_view_class_placeholder.rs b/crates/viewer/re_viewer_context/src/space_view/space_view_class_placeholder.rs index 3aa10c059ac7..40ddb42900c1 100644 --- a/crates/viewer/re_viewer_context/src/space_view/space_view_class_placeholder.rs +++ b/crates/viewer/re_viewer_context/src/space_view/space_view_class_placeholder.rs @@ -1,10 +1,11 @@ +use re_types::SpaceViewClassIdentifier; +use re_ui::UiExt; + use crate::{ SpaceViewClass, SpaceViewClassRegistryError, SpaceViewSpawnHeuristics, SpaceViewState, SpaceViewSystemExecutionError, SpaceViewSystemRegistrator, SystemExecutionOutput, ViewQuery, ViewerContext, }; -use re_types::SpaceViewClassIdentifier; -use re_ui::UiExt; /// A placeholder space view class that can be used when the actual class is not registered. #[derive(Default)] @@ -24,7 +25,7 @@ impl SpaceViewClass for SpaceViewClassPlaceholder { } fn help_markdown(&self, _egui_ctx: &egui::Context) -> String { - "The space view class was not recognized.\n\nThis happens if either the blueprint specifies an invalid space view class or this version of the viewer does not know about this type.".to_owned() + "Placeholder view for unknown space view class".to_owned() } fn on_register( @@ -48,13 +49,27 @@ impl SpaceViewClass for SpaceViewClassPlaceholder { fn ui( &self, - ctx: &ViewerContext<'_>, + _ctx: &ViewerContext<'_>, ui: &mut egui::Ui, _state: &mut dyn SpaceViewState, _query: &ViewQuery<'_>, _system_output: SystemExecutionOutput, ) -> Result<(), SpaceViewSystemExecutionError> { - ui.markdown_ui(&self.help_markdown(ctx.egui_ctx)); + egui::Frame { + inner_margin: egui::Margin::same(re_ui::DesignTokens::view_padding()), + ..Default::default() + } + .show(ui, |ui| { + ui.warning_label("Unknown space view class"); + + ui.markdown_ui( + "This happens if either the blueprint specifies an invalid space view class or \ + this version of the viewer does not know about this type.\n\n\ + \ + **Note**: some views may require a specific Cargo feature to be enabled. In \ + particular, the map view requires the `map_view` feature.", + ); + }); Ok(()) } diff --git a/crates/viewer/re_viewport_blueprint/src/view_properties.rs b/crates/viewer/re_viewport_blueprint/src/view_properties.rs index 2216e84555a1..1accc8427470 100644 --- a/crates/viewer/re_viewport_blueprint/src/view_properties.rs +++ b/crates/viewer/re_viewport_blueprint/src/view_properties.rs @@ -33,10 +33,17 @@ pub struct ViewProperty { /// stored. pub blueprint_store_path: EntityPath, - archetype_name: ArchetypeName, - component_names: Vec, - query_results: LatestAtResults, - blueprint_query: LatestAtQuery, + /// Name of the property archetype. + pub archetype_name: ArchetypeName, + + /// List of all components in this property. + pub component_names: Vec, + + /// Query results for all queries of this property. + pub query_results: LatestAtResults, + + /// Blueprint query used for querying. + pub blueprint_query: LatestAtQuery, } impl ViewProperty { diff --git a/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs b/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs index 239865b24404..daf5c21f9a3b 100644 --- a/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs +++ b/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs @@ -207,6 +207,17 @@ impl ViewportBlueprint { self.space_views.keys() } + /// Find the parent container of a given contents. + /// + /// Returns `None` if this is unknown contents, or if it is the root contaioner. + pub fn parent(&self, needle: &Contents) -> Option { + self.containers + .iter() + .find_map(|(container_id, container)| { + container.contents.contains(needle).then_some(*container_id) + }) + } + pub fn view(&self, space_view: &SpaceViewId) -> Option<&SpaceViewBlueprint> { self.space_views.get(space_view) } diff --git a/pixi.toml b/pixi.toml index 703dce9c25e3..93d423976d6e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -175,13 +175,13 @@ rerun-web = { cmd = "cargo run --package rerun-cli --no-default-features --featu # # This installs the `wasm32-unknown-unknown` rust target if it's not already installed. # (this looks heavy but takes typically below 0.1s!) -rerun-build-web = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --features analytics,grpc --debug" +rerun-build-web = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --no-default-features --features analytics,grpc,map_view --debug" # Compile the web-viewer wasm and the cli. # # This installs the `wasm32-unknown-unknown` rust target if it's not already installed. # (this looks heavy but takes typically below 0.1s!) -rerun-build-web-cli = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --features analytics,grpc --debug && cargo build --package rerun-cli --no-default-features --features web_viewer" +rerun-build-web-cli = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --no-default-features --features analytics,grpc,map_view --debug && cargo build --package rerun-cli --no-default-features --features web_viewer" # Compile and run the web-viewer in release mode via rerun-cli. # @@ -189,7 +189,7 @@ rerun-build-web-cli = "rustup target add wasm32-unknown-unknown && cargo run -p # # This installs the `wasm32-unknown-unknown` rust target if it's not already installed. # (this looks heavy but takes typically below 0.1s!) -rerun-web-release = { cmd = "cargo run --package rerun-cli --no-default-features --features web_viewer --release -- --web-viewer", depends_on = [ +rerun-web-release = { cmd = "cargo run --package rerun-cli --no-default-features --features web_viewer,map_view,grpc --release -- --web-viewer", depends_on = [ "rerun-build-web-release", ] } @@ -197,7 +197,7 @@ rerun-web-release = { cmd = "cargo run --package rerun-cli --no-default-features # # This installs the `wasm32-unknown-unknown` rust target if it's not already installed. # (this looks heavy but takes typically below 0.1s!) -rerun-build-web-release = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --features analytics,grpc --release -g" +rerun-build-web-release = "rustup target add wasm32-unknown-unknown && cargo run -p re_dev_tools -- build-web-viewer --no-default-features --features analytics,grpc,map_view --release -g" rs-check = { cmd = "rustup target add wasm32-unknown-unknown && python scripts/ci/rust_checks.py", depends_on = [ "rerun-build-web", # The checks require the web viewer wasm to be around. diff --git a/rerun_js/web-viewer/build-wasm.mjs b/rerun_js/web-viewer/build-wasm.mjs index 3e54e5020119..f82292d847f5 100644 --- a/rerun_js/web-viewer/build-wasm.mjs +++ b/rerun_js/web-viewer/build-wasm.mjs @@ -31,7 +31,8 @@ function buildWebViewer(mode) { "cargo run -p re_dev_tools -- build-web-viewer", modeFlags, "--target no-modules-base", - "--features grpc", + "--no-default-features", + "--features grpc,map_view", // no `analytics` "-o rerun_js/web-viewer", ].join(" "), ); diff --git a/tests/python/release_checklist/check_graph_time_layout.py b/tests/python/release_checklist/check_graph_time_layout.py new file mode 100644 index 000000000000..3d469e1b72c5 --- /dev/null +++ b/tests/python/release_checklist/check_graph_time_layout.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +import random +from argparse import Namespace +from uuid import uuid4 + +import rerun as rr +import rerun.blueprint as rrb + +README = """\ +# Time-varying graph view + +Please watch out for any twitching, jumping, or other wise unexpected changes to +the layout when navigating the timeline. + +Please check the following: +* Scrub the timeline to see how the graph layout changes over time. +""" + + +def log_readme() -> None: + rr.log("readme", rr.TextDocument(README, media_type=rr.MediaType.MARKDOWN), static=True) + + +def log_graphs() -> None: + nodes = ["root"] + edges = [] + + # Randomly add nodes and edges to the graph + for i in range(50): + existing = random.choice(nodes) + new_node = str(i) + nodes.append(new_node) + edges.append((existing, new_node)) + + rr.set_time_sequence("frame", i) + rr.log("graph", rr.GraphNodes(nodes, labels=nodes), rr.GraphEdges(edges, graph_type=rr.GraphType.Directed)) + + rr.send_blueprint( + rrb.Blueprint( + rrb.Grid( + rrb.GraphView(origin="graph", name="Graph"), + rrb.TextDocumentView(origin="readme", name="Instructions"), + ) + ) + ) + + +def run(args: Namespace) -> None: + rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4()) + + log_readme() + log_graphs() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Interactive release checklist") + rr.script_add_args(parser) + args = parser.parse_args() + run(args) diff --git a/tests/python/release_checklist/check_graph_view.py b/tests/python/release_checklist/check_graph_view.py index dc0b5edeeaf7..97e9964394bd 100644 --- a/tests/python/release_checklist/check_graph_view.py +++ b/tests/python/release_checklist/check_graph_view.py @@ -45,6 +45,13 @@ def log_graphs() -> None: rr.log("graph", rr.GraphNodes(nodes, labels=nodes), rr.GraphEdges(edges, graph_type=rr.GraphType.Directed)) rr.log("graph2", rr.GraphNodes(nodes, labels=nodes), rr.GraphEdges(edges, graph_type=rr.GraphType.Undirected)) + +def run(args: Namespace) -> None: + rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4()) + + log_readme() + log_graphs() + rr.send_blueprint( rrb.Blueprint( rrb.Grid( @@ -57,13 +64,6 @@ def log_graphs() -> None: ) -def run(args: Namespace) -> None: - rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4()) - - log_readme() - log_graphs() - - if __name__ == "__main__": import argparse diff --git a/tests/python/release_checklist/check_graph_view_multi_self_edges.py b/tests/python/release_checklist/check_graph_view_multi_self_edges.py new file mode 100644 index 000000000000..8b43cac93fc0 --- /dev/null +++ b/tests/python/release_checklist/check_graph_view_multi_self_edges.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +from argparse import Namespace +from uuid import uuid4 + +import rerun as rr +import rerun.blueprint as rrb + +README = """\ +# Graph view (multi- and self-edges) + +Please check that both graph views show: +* two self-edges for `A`, a single one for `B`. +* Additionally, there should be: + * two edges from `A` to `B`. + * one edge from `B` to `A`. + * one edge connecting `B` to an implicit node `C`. + * a self-edge for `C`. +""" + + +def log_readme() -> None: + rr.log("readme", rr.TextDocument(README, media_type=rr.MediaType.MARKDOWN), static=True) + + +def log_multi_and_self_graph() -> None: + rr.log( + "graph", + rr.GraphNodes(["A", "B"], labels=["A", "B"]), + rr.GraphEdges( + [ + # self-edges + ("A", "A"), + ("B", "B"), + # duplicated edges + ("A", "B"), + ("A", "B"), + ("B", "A"), + # duplicated self-edges + ("A", "A"), + # implicit edges + ("B", "C"), + ("C", "C"), + ], + graph_type=rr.GraphType.Directed, + ), + ) + + +def run(args: Namespace) -> None: + rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4()) + + log_multi_and_self_graph() + log_readme() + + rr.send_blueprint( + rrb.Blueprint( + rrb.Grid( + rrb.GraphView(origin="graph", name="Multiple edges and self-edges"), + rrb.GraphView( + origin="graph", + name="Multiple edges and self-edges (without labels)", + defaults=[rr.components.ShowLabels(False)], + ), + rrb.TextDocumentView(origin="readme", name="Instructions"), + ) + ) + ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Interactive release checklist") + rr.script_add_args(parser) + args = parser.parse_args() + run(args) From 077f041f609f4d14949a83185a000bb2e6518921 Mon Sep 17 00:00:00 2001 From: Zeljko Mihaljcic <7150613+zehiko@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:24:34 +0100 Subject: [PATCH 6/6] address comment --- .../store/re_protos/proto/rerun/v0/remote_store.proto | 10 +++++----- crates/store/re_protos/src/v0/rerun.remote_store.v0.rs | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/store/re_protos/proto/rerun/v0/remote_store.proto b/crates/store/re_protos/proto/rerun/v0/remote_store.proto index cd189f3b1c22..e98a40958d0d 100644 --- a/crates/store/re_protos/proto/rerun/v0/remote_store.proto +++ b/crates/store/re_protos/proto/rerun/v0/remote_store.proto @@ -80,11 +80,10 @@ enum EncoderVersion { // ----------------- QueryCatalog ----------------- message QueryCatalogRequest { - // define which columns should be returned / projected - // we define a separate message to make it optional. - // If not provided, all columns should be returned + // Column projection - define which columns should be returned. + // Providing it is optional, if not provided, all columns should be returned ColumnProjection column_projection = 1; - // filter out specific recordings metadata + // Filter specific recordings that match the criteria (selection) CatalogFilter filter = 2; } @@ -93,7 +92,8 @@ message ColumnProjection { } message CatalogFilter { - // filter out specific recordings + // Filtering is very simple right now, we can only select + // recordings by their ids. repeated RecordingId recording_ids = 1; } diff --git a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs index 731659e42c64..e956a1082d00 100644 --- a/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs +++ b/crates/store/re_protos/src/v0/rerun.remote_store.v0.rs @@ -275,12 +275,11 @@ pub struct QueryResponse { } #[derive(Clone, PartialEq, ::prost::Message)] pub struct QueryCatalogRequest { - /// define which columns should be returned / projected - /// we define a separate message to make it optional. - /// If not provided, all columns should be returned + /// Column projection - define which columns should be returned. + /// Providing it is optional, if not provided, all columns should be returned #[prost(message, optional, tag = "1")] pub column_projection: ::core::option::Option, - /// filter out specific recordings metadata + /// Filter specific recordings that match the criteria (selection) #[prost(message, optional, tag = "2")] pub filter: ::core::option::Option, } @@ -291,7 +290,8 @@ pub struct ColumnProjection { } #[derive(Clone, PartialEq, ::prost::Message)] pub struct CatalogFilter { - /// filter out specific recordings + /// Filtering is very simple right now, we can only select + /// recordings by their ids. #[prost(message, repeated, tag = "1")] pub recording_ids: ::prost::alloc::vec::Vec, }