diff --git a/crates/re_sdk/src/recording_stream.rs b/crates/re_sdk/src/recording_stream.rs index 62f80b81c9f9..c0b1a0cf1cf9 100644 --- a/crates/re_sdk/src/recording_stream.rs +++ b/crates/re_sdk/src/recording_stream.rs @@ -1522,7 +1522,11 @@ impl RecordingStream { /// terms of data durability and ordering. /// See [`Self::set_sink`] for more information. pub fn connect(&self) { - self.connect_opts(crate::default_server_addr(), crate::default_flush_timeout()); + self.connect_opts( + crate::default_server_addr(), + crate::default_flush_timeout(), + None, + ); } /// Swaps the underlying sink for a [`crate::log_sink::TcpSink`] sink pre-configured to use @@ -1539,13 +1543,23 @@ impl RecordingStream { &self, addr: std::net::SocketAddr, flush_timeout: Option, + blueprint: Option>, ) { if forced_sink_path().is_some() { re_log::debug!("Ignored setting new TcpSink since _RERUN_FORCE_SINK is set"); return; } - self.set_sink(Box::new(crate::log_sink::TcpSink::new(addr, flush_timeout))); + let sink = crate::log_sink::TcpSink::new(addr, flush_timeout); + + // If a blueprint was provided, send it first. + if let Some(blueprint) = blueprint { + for msg in blueprint { + sink.send(msg); + } + } + + self.set_sink(Box::new(sink)); } /// Spawns a new Rerun Viewer process from an executable available in PATH, then swaps the @@ -1598,7 +1612,7 @@ impl RecordingStream { spawn(opts)?; - self.connect_opts(opts.connect_addr(), flush_timeout); + self.connect_opts(opts.connect_addr(), flush_timeout, None); Ok(()) } diff --git a/crates/re_types/definitions/rerun/blueprint/archetypes/viewport_blueprint.fbs b/crates/re_types/definitions/rerun/blueprint/archetypes/viewport_blueprint.fbs index 8a00279674e7..b87721d45262 100644 --- a/crates/re_types/definitions/rerun/blueprint/archetypes/viewport_blueprint.fbs +++ b/crates/re_types/definitions/rerun/blueprint/archetypes/viewport_blueprint.fbs @@ -15,10 +15,9 @@ table ViewportBlueprint ( ) { // --- Required --- - /// All of the space-views that belong to the viewport. - space_views: [rerun.blueprint.components.IncludedSpaceView] ("attr.rerun.component_required", order: 1000); - // --- Optional --- + /// All of the space-views that belong to the viewport. + space_views: [rerun.blueprint.components.IncludedSpaceView] ("attr.rerun.component_optional", nullable, order: 1000); /// The layout of the space-views root_container: rerun.blueprint.components.RootContainer ("attr.rerun.component_optional", nullable, order: 2500); diff --git a/crates/re_viewport/src/blueprint/archetypes/viewport_blueprint.rs b/crates/re_viewport/src/blueprint/archetypes/viewport_blueprint.rs index b902fbe52da0..1773aae2afe9 100644 --- a/crates/re_viewport/src/blueprint/archetypes/viewport_blueprint.rs +++ b/crates/re_viewport/src/blueprint/archetypes/viewport_blueprint.rs @@ -25,7 +25,7 @@ use ::re_types_core::{DeserializationError, DeserializationResult}; #[derive(Clone, Debug, Default)] pub struct ViewportBlueprint { /// All of the space-views that belong to the viewport. - pub space_views: Vec, + pub space_views: Option>, /// The layout of the space-views pub root_container: Option, @@ -69,7 +69,7 @@ impl ::re_types_core::SizeBytes for ViewportBlueprint { #[inline] fn is_pod() -> bool { - >::is_pod() + >>::is_pod() && >::is_pod() && >::is_pod() && >::is_pod() @@ -78,17 +78,18 @@ impl ::re_types_core::SizeBytes for ViewportBlueprint { } } -static REQUIRED_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 1usize]> = - once_cell::sync::Lazy::new(|| ["rerun.blueprint.components.IncludedSpaceView".into()]); +static REQUIRED_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 0usize]> = + once_cell::sync::Lazy::new(|| []); static RECOMMENDED_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 1usize]> = once_cell::sync::Lazy::new(|| ["rerun.blueprint.components.ViewportBlueprintIndicator".into()]); -static OPTIONAL_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 6usize]> = +static OPTIONAL_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 7usize]> = once_cell::sync::Lazy::new(|| { [ "rerun.blueprint.components.AutoLayout".into(), "rerun.blueprint.components.AutoSpaceViews".into(), + "rerun.blueprint.components.IncludedSpaceView".into(), "rerun.blueprint.components.RootContainer".into(), "rerun.blueprint.components.SpaceViewMaximized".into(), "rerun.blueprint.components.ViewerRecommendationHash".into(), @@ -99,10 +100,10 @@ static OPTIONAL_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 6usize]> = static ALL_COMPONENTS: once_cell::sync::Lazy<[ComponentName; 8usize]> = once_cell::sync::Lazy::new(|| { [ - "rerun.blueprint.components.IncludedSpaceView".into(), "rerun.blueprint.components.ViewportBlueprintIndicator".into(), "rerun.blueprint.components.AutoLayout".into(), "rerun.blueprint.components.AutoSpaceViews".into(), + "rerun.blueprint.components.IncludedSpaceView".into(), "rerun.blueprint.components.RootContainer".into(), "rerun.blueprint.components.SpaceViewMaximized".into(), "rerun.blueprint.components.ViewerRecommendationHash".into(), @@ -161,17 +162,19 @@ impl ::re_types_core::Archetype for ViewportBlueprint { .into_iter() .map(|(name, array)| (name.full_name(), array)) .collect(); - let space_views = { - let array = arrays_by_name - .get("rerun.blueprint.components.IncludedSpaceView") - .ok_or_else(DeserializationError::missing_data) - .with_context("rerun.blueprint.archetypes.ViewportBlueprint#space_views")?; - ::from_arrow_opt(&**array) - .with_context("rerun.blueprint.archetypes.ViewportBlueprint#space_views")? - .into_iter() - .map(|v| v.ok_or_else(DeserializationError::missing_data)) - .collect::>>() - .with_context("rerun.blueprint.archetypes.ViewportBlueprint#space_views")? + let space_views = if let Some(array) = + arrays_by_name.get("rerun.blueprint.components.IncludedSpaceView") + { + Some({ + ::from_arrow_opt(&**array) + .with_context("rerun.blueprint.archetypes.ViewportBlueprint#space_views")? + .into_iter() + .map(|v| v.ok_or_else(DeserializationError::missing_data)) + .collect::>>() + .with_context("rerun.blueprint.archetypes.ViewportBlueprint#space_views")? + }) + } else { + None }; let root_container = if let Some(array) = arrays_by_name.get("rerun.blueprint.components.RootContainer") { @@ -249,7 +252,9 @@ impl ::re_types_core::AsComponents for ViewportBlueprint { use ::re_types_core::Archetype as _; [ Some(Self::indicator()), - Some((&self.space_views as &dyn ComponentBatch).into()), + self.space_views + .as_ref() + .map(|comp_batch| (comp_batch as &dyn ComponentBatch).into()), self.root_container .as_ref() .map(|comp| (comp as &dyn ComponentBatch).into()), @@ -273,18 +278,14 @@ impl ::re_types_core::AsComponents for ViewportBlueprint { #[inline] fn num_instances(&self) -> usize { - self.space_views.len() + 0 } } impl ViewportBlueprint { - pub fn new( - space_views: impl IntoIterator< - Item = impl Into, - >, - ) -> Self { + pub fn new() -> Self { Self { - space_views: space_views.into_iter().map(Into::into).collect(), + space_views: None, root_container: None, maximized: None, auto_layout: None, @@ -293,6 +294,17 @@ impl ViewportBlueprint { } } + #[inline] + pub fn with_space_views( + mut self, + space_views: impl IntoIterator< + Item = impl Into, + >, + ) -> Self { + self.space_views = Some(space_views.into_iter().map(Into::into).collect()); + self + } + #[inline] pub fn with_root_container( mut self, diff --git a/crates/re_viewport/src/viewport_blueprint.rs b/crates/re_viewport/src/viewport_blueprint.rs index 428a7979a90b..439f913b4caa 100644 --- a/crates/re_viewport/src/viewport_blueprint.rs +++ b/crates/re_viewport/src/viewport_blueprint.rs @@ -98,8 +98,11 @@ impl ViewportBlueprint { } }; - let space_view_ids: Vec = - space_views.into_iter().map(|id| id.0.into()).collect(); + let space_view_ids: Vec = space_views + .unwrap_or(vec![]) + .iter() + .map(|id| id.0.into()) + .collect(); let space_views: BTreeMap = space_view_ids .into_iter() diff --git a/crates/rerun_c/src/lib.rs b/crates/rerun_c/src/lib.rs index e814a9dd530c..9f04baba1cad 100644 --- a/crates/rerun_c/src/lib.rs +++ b/crates/rerun_c/src/lib.rs @@ -408,7 +408,7 @@ fn rr_recording_stream_connect_impl( } else { None }; - stream.connect_opts(tcp_addr, flush_timeout); + stream.connect_opts(tcp_addr, flush_timeout, None); Ok(()) } diff --git a/examples/python/blueprint/main.py b/examples/python/blueprint/main.py index 006bc2a3a88b..2a8bc427949b 100755 --- a/examples/python/blueprint/main.py +++ b/examples/python/blueprint/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Example of using the blueprint APIs to configure Rerun.""" +# TODO(jleibs): Update this example to use the new APIs from __future__ import annotations import argparse @@ -26,15 +27,11 @@ def main() -> None: rr.init( "Blueprint demo", init_logging=False, - exp_init_blueprint=not args.skip_blueprint, - exp_add_to_app_default_blueprint=args.no_append_default, spawn=True, ) else: rr.init( "Blueprint demo", - exp_init_blueprint=not args.skip_blueprint, - exp_add_to_app_default_blueprint=args.no_append_default, spawn=True, ) diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.cpp b/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.cpp index e4f142f58638..82770516049d 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.cpp +++ b/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.cpp @@ -16,8 +16,8 @@ namespace rerun { std::vector cells; cells.reserve(7); - { - auto result = DataCell::from_loggable(archetype.space_views); + if (archetype.space_views.has_value()) { + auto result = DataCell::from_loggable(archetype.space_views.value()); RR_RETURN_NOT_OK(result.error); cells.push_back(std::move(result.value)); } diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.hpp b/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.hpp index 31458d45b3f4..fff310ce9a58 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.hpp +++ b/rerun_cpp/src/rerun/blueprint/archetypes/viewport_blueprint.hpp @@ -24,7 +24,7 @@ namespace rerun::blueprint::archetypes { /// **Archetype**: The top-level description of the Viewport. struct ViewportBlueprint { /// All of the space-views that belong to the viewport. - Collection space_views; + std::optional> space_views; /// The layout of the space-views std::optional root_container; @@ -65,10 +65,14 @@ namespace rerun::blueprint::archetypes { ViewportBlueprint() = default; ViewportBlueprint(ViewportBlueprint&& other) = default; - explicit ViewportBlueprint( + /// All of the space-views that belong to the viewport. + ViewportBlueprint with_space_views( Collection _space_views - ) - : space_views(std::move(_space_views)) {} + ) && { + space_views = std::move(_space_views); + // See: https://github.com/rerun-io/rerun/issues/4027 + RR_WITH_MAYBE_UNINITIALIZED_DISABLED(return std::move(*this);) + } /// The layout of the space-views ViewportBlueprint with_root_container( @@ -128,7 +132,7 @@ namespace rerun::blueprint::archetypes { /// Returns the number of primary instances of this archetype. size_t num_instances() const { - return space_views.size(); + return 0; } }; diff --git a/rerun_py/rerun_sdk/rerun/__init__.py b/rerun_py/rerun_sdk/rerun/__init__.py index 16087f9ff5dc..5cd47eb194f4 100644 --- a/rerun_py/rerun_sdk/rerun/__init__.py +++ b/rerun_py/rerun_sdk/rerun/__init__.py @@ -142,6 +142,7 @@ ViewCoordinates, ) from .archetypes.boxes2d_ext import Box2DFormat +from .blueprint.api import BlueprintLike from .components import ( Material, MediaType, @@ -230,6 +231,7 @@ def _init_recording_stream() -> None: set_time_nanos, disable_timeline, reset_time, + log, ] + [fn for name, fn in getmembers(sys.modules[__name__], isfunction) if name.startswith("log_")] ) @@ -247,8 +249,7 @@ def init( init_logging: bool = True, default_enabled: bool = True, strict: bool = False, - exp_init_blueprint: bool = False, - exp_add_to_app_default_blueprint: bool = True, + blueprint: BlueprintLike | None = None, ) -> None: """ Initialize the Rerun SDK with a user-chosen application id (name). @@ -316,10 +317,8 @@ def init( strict If `True`, an exceptions is raised on use error (wrong parameter types, etc.). If `False`, errors are logged as warnings instead. - exp_init_blueprint - (Experimental) Should we initialize the blueprint for this application? - exp_add_to_app_default_blueprint - (Experimental) Should the blueprint append to the existing app-default blueprint instead of creating a new one. + blueprint + A blueprint to use for this application. If not provided, a new one will be created. """ @@ -346,21 +345,11 @@ def init( spawn=False, default_enabled=default_enabled, ) - if exp_init_blueprint: - experimental.new_blueprint( - application_id=application_id, - blueprint_id=recording_id, - make_default=True, - make_thread_default=False, - spawn=False, - add_to_app_default_blueprint=exp_add_to_app_default_blueprint, - default_enabled=default_enabled, - ) if spawn: from rerun.sinks import spawn as _spawn - _spawn() + _spawn(blueprint=blueprint) # TODO(#3793): defaulting recording_id to authkey should be opt-in diff --git a/rerun_py/rerun_sdk/rerun/_log.py b/rerun_py/rerun_sdk/rerun/_log.py index 216977848cf3..30a182364a4f 100644 --- a/rerun_py/rerun_sdk/rerun/_log.py +++ b/rerun_py/rerun_sdk/rerun/_log.py @@ -218,6 +218,9 @@ def log_components( if None, use the global default from `rerun.strict_mode()` """ + # Convert to a native recording + recording = RecordingStream.to_native(recording) + instanced: dict[str, pa.Array] = {} splats: dict[str, pa.Array] = {} diff --git a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py index d9bd07a140a0..ba7be91efa09 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py @@ -1,5 +1,18 @@ from __future__ import annotations -__all__ = ["archetypes", "datatypes", "components"] +__all__ = [ + "archetypes", + "BlueprintLike", + "components", + "datatypes", + "Grid", + "Horizontal", + "Spatial2D", + "Spatial3D", + "Tabs", + "Vertical", + "Viewport", +] from . import archetypes, components, datatypes +from .api import BlueprintLike, Grid, Horizontal, Spatial2D, Spatial3D, Tabs, Vertical, Viewport diff --git a/rerun_py/rerun_sdk/rerun/blueprint/api.py b/rerun_py/rerun_sdk/rerun/blueprint/api.py new file mode 100644 index 000000000000..cf85e5dbb2b1 --- /dev/null +++ b/rerun_py/rerun_sdk/rerun/blueprint/api.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import itertools +import uuid +from typing import Iterable, Optional, Sequence, Union + +import rerun_bindings as bindings + +from ..datatypes import EntityPathLike, Utf8Like +from ..recording import MemoryRecording +from ..recording_stream import RecordingStream +from .archetypes import ( + ContainerBlueprint, + SpaceViewBlueprint, + SpaceViewContents, + ViewportBlueprint, +) +from .components import ColumnShareArrayLike, RowShareArrayLike +from .components.container_kind import ContainerKind, ContainerKindLike + +SpaceViewContentsLike = Union[str, Sequence[str], Utf8Like, SpaceViewContents] + + +class SpaceView: + """ + Base class for all space view types. + + Consider using one of the subclasses instead of this class directly: + - [Spatial3D][] for 3D space views + - [Spatial2D][] for 2D space views + + This is an ergonomic helper on top of [rerun.blueprint.archetypes.SpaceViewBlueprint][]. + """ + + def __init__( + self, + class_identifier: Utf8Like, + origin: EntityPathLike, + contents: SpaceViewContentsLike, + ): + """ + Construct a blueprint for a new space view. + + Parameters + ---------- + class_identifier + The class of the space view to add. This must correspond to a known space view class. + Prefer to use one of the subclasses of `SpaceView` which will populate this for you. + origin + The `EntityPath` to use as the origin of this space view. All other entities will be transformed + to be displayed relative to this origin. + contents + The contents of the space view. Most commonly specified as a query expression. The individual + sub-expressions must either be newline separate, or provided as a list of strings. + + """ + self.id = uuid.uuid4() + self.class_identifier = class_identifier + self.origin = origin + self.contents = contents + + def blueprint_path(self) -> str: + """ + The blueprint path where this space view will be logged. + + Note that although this is an `EntityPath`, is scoped to the blueprint tree and + not a part of the regular data hierarchy. + """ + return f"space_view/{self.id}" + + def _log_to_stream(self, stream: RecordingStream) -> None: + """Internal method to convert to an archetype and log to the stream.""" + # Handle the cases for SpaceViewContentsLike + # TODO(#5483): Move this into a QueryExpressionExt class. + # This is a little bit tricky since QueryExpression is a delegating component for Utf8, + # and delegating components make extending things in this way a bit more complicated. + if isinstance(self.contents, str): + # str + contents = SpaceViewContents(query=self.contents) + elif isinstance(self.contents, Sequence) and len(self.contents) > 0 and isinstance(self.contents[0], str): + # list[str] + contents = SpaceViewContents(query="\n".join(self.contents)) + elif isinstance(self.contents, SpaceViewContents): + # SpaceViewContents + contents = self.contents + else: + # Anything else we let SpaceViewContents handle + contents = SpaceViewContents(query=self.contents) # type: ignore[arg-type] + + stream.log(self.blueprint_path() + "/SpaceViewContents", contents) # type: ignore[attr-defined] + + arch = SpaceViewBlueprint( + class_identifier=self.class_identifier, + space_origin=self.origin, + ) + + stream.log(self.blueprint_path(), arch, recording=stream) # type: ignore[attr-defined] + + def _iter_space_views(self) -> Iterable[bytes]: + """Internal method to iterate over all of the space views in the blueprint.""" + # TODO(jleibs): This goes away when we get rid of `space_views` from the viewport and just use + # the entity-path lookup instead. + return [self.id.bytes] + + +class Spatial3D(SpaceView): + """A Spatial 3D space view.""" + + def __init__(self, origin: EntityPathLike = "/", contents: SpaceViewContentsLike = "/**"): + """ + Construct a blueprint for a new 3D space view. + + Parameters + ---------- + origin + The `EntityPath` to use as the origin of this space view. All other entities will be transformed + to be displayed relative to this origin. + contents + The contents of the space view. Most commonly specified as a query expression. The individual + sub-expressions must either be newline separate, or provided as a list of strings. + See: [rerun.blueprint.components.QueryExpression][]. + + """ + super().__init__("3D", origin, contents) + + +class Spatial2D(SpaceView): + """A Spatial 2D space view.""" + + def __init__(self, origin: EntityPathLike = "/", contents: SpaceViewContentsLike = "/**"): + """ + Construct a blueprint for a new 2D space view. + + Parameters + ---------- + origin + The `EntityPath` to use as the origin of this space view. All other entities will be transformed + to be displayed relative to this origin. + contents + The contents of the space view. Most commonly specified as a query expression. The individual + sub-expressions must either be newline separate, or provided as a list of strings. + See: [rerun.blueprint.components.QueryExpression][]. + + """ + super().__init__("2D", origin, contents) + + +class Container: + """ + Base class for all container types. + + Consider using one of the subclasses instead of this class directly: + - [Horizontal][] for horizontal containers + - [Vertical][] for vertical containers + - [Grid][] for grid containers + - [Tabs][] for tab containers + + This is an ergonomic helper on top of [rerun.blueprint.archetypes.ContainerBlueprint][]. + """ + + def __init__( + self, + *contents: Container | SpaceView, + kind: ContainerKindLike, + column_shares: Optional[ColumnShareArrayLike] = None, + row_shares: Optional[RowShareArrayLike] = None, + grid_columns: Optional[int] = None, + ): + """ + Construct a new container. + + Parameters + ---------- + *contents: + All positional arguments are the contents of the container, which may be either other containers or space views. + kind + The kind of the container. This must correspond to a known container kind. + Prefer to use one of the subclasses of `Container` which will populate this for you. + column_shares + The layout shares of the columns in the container. The share is used to determine what fraction of the total width each + column should take up. The column with index `i` will take up the fraction `shares[i] / total_shares`. + This is only applicable to `Horizontal` or `Grid` containers. + row_shares + The layout shares of the rows in the container. The share is used to determine what fraction of the total height each + row should take up. The ros with index `i` will take up the fraction `shares[i] / total_shares`. + This is only applicable to `Vertical` or `Grid` containers. + grid_columns + The number of columns in the grid. This is only applicable to `Grid` containers. + + """ + self.id = uuid.uuid4() + self.kind = kind + self.contents = contents + self.column_shares = column_shares + self.row_shares = row_shares + self.grid_columns = grid_columns + + def blueprint_path(self) -> str: + """ + The blueprint path where this space view will be logged. + + Note that although this is an `EntityPath`, is scoped to the blueprint tree and + not a part of the regular data hierarchy. + """ + return f"container/{self.id}" + + def _log_to_stream(self, stream: RecordingStream) -> None: + """Internal method to convert to an archetype and log to the stream.""" + for sub in self.contents: + sub._log_to_stream(stream) + + arch = ContainerBlueprint( + container_kind=self.kind, + contents=[sub.blueprint_path() for sub in self.contents], + col_shares=self.column_shares, + row_shares=self.row_shares, + visible=True, + grid_columns=self.grid_columns, + ) + + stream.log(self.blueprint_path(), arch) # type: ignore[attr-defined] + + def _iter_space_views(self) -> Iterable[bytes]: + """Internal method to iterate over all of the space views in the blueprint.""" + # TODO(jleibs): This goes away when we get rid of `space_views` from the viewport and just use + # the entity-path lookup instead. + return itertools.chain.from_iterable(sub._iter_space_views() for sub in self.contents) + + +class Horizontal(Container): + """A horizontal container.""" + + def __init__(self, *contents: Container | SpaceView, column_shares: Optional[ColumnShareArrayLike] = None): + """ + Construct a new horizontal container. + + Parameters + ---------- + *contents: + All positional arguments are the contents of the container, which may be either other containers or space views. + column_shares + The layout shares of the columns in the container. The share is used to determine what fraction of the total width each + column should take up. The column with index `i` will take up the fraction `shares[i] / total_shares`. + + """ + super().__init__(*contents, kind=ContainerKind.Horizontal, column_shares=column_shares) + + +class Vertical(Container): + """A vertical container.""" + + def __init__(self, *contents: Container | SpaceView, row_shares: Optional[RowShareArrayLike] = None): + """ + Construct a new vertical container. + + Parameters + ---------- + *contents: + All positional arguments are the contents of the container, which may be either other containers or space views. + row_shares + The layout shares of the rows in the container. The share is used to determine what fraction of the total height each + row should take up. The ros with index `i` will take up the fraction `shares[i] / total_shares`. + + """ + super().__init__(*contents, kind=ContainerKind.Vertical, row_shares=row_shares) + + +class Grid(Container): + """A grid container.""" + + def __init__( + self, + *contents: Container | SpaceView, + column_shares: Optional[ColumnShareArrayLike] = None, + row_shares: Optional[RowShareArrayLike] = None, + grid_columns: Optional[int] = None, + ): + """ + Construct a new grid container. + + Parameters + ---------- + *contents: + All positional arguments are the contents of the container, which may be either other containers or space views. + column_shares + The layout shares of the columns in the container. The share is used to determine what fraction of the total width each + column should take up. The column with index `i` will take up the fraction `shares[i] / total_shares`. + row_shares + The layout shares of the rows in the container. The share is used to determine what fraction of the total height each + row should take up. The ros with index `i` will take up the fraction `shares[i] / total_shares`. + grid_columns + The number of columns in the grid. + + """ + super().__init__( + *contents, + kind=ContainerKind.Grid, + column_shares=column_shares, + row_shares=row_shares, + grid_columns=grid_columns, + ) + + +class Tabs(Container): + """A tab container.""" + + def __init__(self, *contents: Container | SpaceView): + """ + Construct a new tab container. + + Parameters + ---------- + *contents: + All positional arguments are the contents of the container, which may be either other containers or space views. + + """ + super().__init__(*contents, kind=ContainerKind.Tabs) + + +class Viewport: + """ + The top-level description of the Viewport. + + This is an ergonomic helper on top of [rerun.blueprint.archetypes.ViewportBlueprint][]. + """ + + def __init__(self, root_container: Container): + """ + Construct a new viewport. + + Parameters + ---------- + root_container: + The container that sits at the top of the viewport hierarchy. The only content visible + in this viewport must be contained within this container. + + """ + self.root_container = root_container + + def blueprint_path(self) -> str: + """ + The blueprint path where this space view will be logged. + + Note that although this is an `EntityPath`, is scoped to the blueprint tree and + not a part of the regular data hierarchy. + """ + return "viewport" + + def _log_to_stream(self, stream: RecordingStream) -> None: + """Internal method to convert to an archetype and log to the stream.""" + self.root_container._log_to_stream(stream) + + arch = ViewportBlueprint( + space_views=list(self.root_container._iter_space_views()), + root_container=self.root_container.id.bytes, + auto_layout=False, + auto_space_views=False, + ) + + stream.log(self.blueprint_path(), arch) # type: ignore[attr-defined] + + +BlueprintLike = Union[Viewport, Container, SpaceView] + + +def create_in_memory_blueprint(*, application_id: str, blueprint: BlueprintLike) -> MemoryRecording: + """Internal rerun helper to convert a `BlueprintLike` into a stream that can be sent to the viewer.""" + + # Add trivial wrappers as necessary + if isinstance(blueprint, SpaceView): + blueprint = Viewport(Grid(blueprint)) + elif isinstance(blueprint, Container): + blueprint = Viewport(blueprint) + + blueprint_stream = RecordingStream( + bindings.new_blueprint( + application_id=application_id, + ) + ) + + # TODO(jleibs): This should use a monotonic seq + blueprint_stream.set_time_seconds("blueprint", 1) # type: ignore[attr-defined] + + blueprint._log_to_stream(blueprint_stream) + + return blueprint_stream.memory_recording() # type: ignore[attr-defined, no-any-return] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/viewport_blueprint.py b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/viewport_blueprint.py index fcace42edb95..5e8a72a7b33b 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/viewport_blueprint.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/viewport_blueprint.py @@ -23,8 +23,8 @@ class ViewportBlueprint(Archetype): def __init__( self: Any, - space_views: datatypes.UuidArrayLike, *, + space_views: datatypes.UuidArrayLike | None = None, root_container: datatypes.UuidLike | None = None, maximized: datatypes.UuidLike | None = None, auto_layout: blueprint_components.AutoLayoutLike | None = None, @@ -94,9 +94,10 @@ def _clear(cls) -> ViewportBlueprint: inst.__attrs_clear__() return inst - space_views: blueprint_components.IncludedSpaceViewBatch = field( - metadata={"component": "required"}, - converter=blueprint_components.IncludedSpaceViewBatch._required, # type: ignore[misc] + space_views: blueprint_components.IncludedSpaceViewBatch | None = field( + metadata={"component": "optional"}, + default=None, + converter=blueprint_components.IncludedSpaceViewBatch._optional, # type: ignore[misc] ) # All of the space-views that belong to the viewport. # diff --git a/rerun_py/rerun_sdk/rerun/sinks.py b/rerun_py/rerun_sdk/rerun/sinks.py index 5820591386f0..0f0f7d4f22e1 100644 --- a/rerun_py/rerun_sdk/rerun/sinks.py +++ b/rerun_py/rerun_sdk/rerun/sinks.py @@ -6,14 +6,19 @@ import rerun_bindings as bindings # type: ignore[attr-defined] +from rerun.blueprint.api import BlueprintLike, create_in_memory_blueprint from rerun.recording import MemoryRecording -from rerun.recording_stream import RecordingStream +from rerun.recording_stream import RecordingStream, get_application_id # --- Sinks --- def connect( - addr: str | None = None, *, flush_timeout_sec: float | None = 2.0, recording: RecordingStream | None = None + addr: str | None = None, + *, + flush_timeout_sec: float | None = 2.0, + blueprint: BlueprintLike | None = None, + recording: RecordingStream | None = None, ) -> None: """ Connect to a remote Rerun Viewer on the given ip:port. @@ -30,14 +35,28 @@ def connect( The minimum time the SDK will wait during a flush before potentially dropping data if progress is not being made. Passing `None` indicates no timeout, and can cause a call to `flush` to block indefinitely. + blueprint: Optional[BlueprintLike] + An optional blueprint to configure the UI. recording: Specifies the [`rerun.RecordingStream`][] to use. If left unspecified, defaults to the current active data recording, if there is one. See also: [`rerun.init`][], [`rerun.set_global_data_recording`][]. """ + application_id = get_application_id(recording=recording) recording = RecordingStream.to_native(recording) - bindings.connect(addr=addr, flush_timeout_sec=flush_timeout_sec, recording=recording) + + if application_id is None: + raise ValueError( + "No application id found. You must call rerun.init before connecting to a viewer, or provide a recording." + ) + + # If a blueprint is provided, we need to create a blueprint storage object + blueprint_storage = None + if blueprint is not None: + blueprint_storage = create_in_memory_blueprint(application_id=application_id, blueprint=blueprint).storage + + bindings.connect(addr=addr, flush_timeout_sec=flush_timeout_sec, blueprint=blueprint_storage, recording=recording) _connect = connect # we need this because Python scoping is horrible @@ -204,6 +223,7 @@ def spawn( port: int = 9876, connect: bool = True, memory_limit: str = "75%", + blueprint: BlueprintLike | None = None, recording: RecordingStream | None = None, ) -> None: """ @@ -228,6 +248,8 @@ def spawn( Specifies the [`rerun.RecordingStream`][] to use if `connect = True`. If left unspecified, defaults to the current active data recording, if there is one. See also: [`rerun.init`][], [`rerun.set_global_data_recording`][]. + blueprint: Optional[BlueprintLike] + An optional blueprint to configure the UI. """ @@ -286,4 +308,4 @@ def spawn( sleep(0.1) if connect: - _connect(f"127.0.0.1:{port}", recording=recording) + _connect(f"127.0.0.1:{port}", recording=recording, blueprint=blueprint) diff --git a/rerun_py/src/python_bridge.rs b/rerun_py/src/python_bridge.rs index 537acb11222b..c0acbd5796b1 100644 --- a/rerun_py/src/python_bridge.rs +++ b/rerun_py/src/python_bridge.rs @@ -535,10 +535,11 @@ fn is_enabled(recording: Option<&PyRecordingStream>) -> bool { } #[pyfunction] -#[pyo3(signature = (addr = None, flush_timeout_sec=rerun::default_flush_timeout().unwrap().as_secs_f32(), recording = None))] +#[pyo3(signature = (addr = None, flush_timeout_sec=rerun::default_flush_timeout().unwrap().as_secs_f32(), blueprint = None, recording = None))] fn connect( addr: Option, flush_timeout_sec: Option, + blueprint: Option<&PyMemorySinkStorage>, recording: Option<&PyRecordingStream>, py: Python<'_>, ) -> PyResult<()> { @@ -553,18 +554,9 @@ fn connect( // The call to connect may internally flush. // Release the GIL in case any flushing behavior needs to cleanup a python object. py.allow_threads(|| { - if let Some(recording) = recording { - // If the user passed in a recording, use it - recording.connect_opts(addr, flush_timeout); - } else { - // Otherwise, connect both global defaults - if let Some(recording) = get_data_recording(None) { - recording.connect_opts(addr, flush_timeout); - }; - if let Some(blueprint) = get_blueprint_recording(None) { - blueprint.connect_opts(addr, flush_timeout); - }; - } + if let Some(recording) = get_data_recording(recording) { + recording.connect_opts(addr, flush_timeout, blueprint.map(|b| b.inner.take())); + }; flush_garbage_queue(); }); diff --git a/rerun_py/tests/unit/test_viewport_blueprint.py b/rerun_py/tests/unit/test_viewport_blueprint.py index 153ef1ea467a..0f6ab7ee67c2 100644 --- a/rerun_py/tests/unit/test_viewport_blueprint.py +++ b/rerun_py/tests/unit/test_viewport_blueprint.py @@ -75,7 +75,7 @@ def test_viewport_blueprint() -> None: ")", ) arch = ViewportBlueprint( - space_views, + space_views=space_views, root_container=root_container, maximized=maximized, auto_layout=auto_layout, diff --git a/tests/python/blueprint/main.py b/tests/python/blueprint/main.py new file mode 100644 index 000000000000..4b042b9e9fdf --- /dev/null +++ b/tests/python/blueprint/main.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import rerun as rr +from numpy.random import default_rng +from rerun.blueprint import Grid, Horizontal, Spatial2D, Spatial3D, Tabs, Vertical + +if __name__ == "__main__": + blueprint = Vertical( + Spatial3D(origin="/test1"), + Horizontal( + Tabs( + Spatial3D(origin="/test1"), + Spatial2D(origin="/test2"), + ), + Grid( + Spatial3D(origin="/test1"), + Spatial2D(origin="/test2"), + Spatial3D(origin="/test1"), + Spatial2D(origin="/test2"), + grid_columns=3, + column_shares=[1, 1, 1], + ), + column_shares=[1, 2], + ), + row_shares=[2, 1], + ) + + rr.init( + "rerun_example_blueprint_test", + spawn=True, + blueprint=blueprint, + ) + + rng = default_rng(12345) + positions = rng.uniform(-5, 5, size=[10, 3]) + colors = rng.uniform(0, 255, size=[10, 3]) + radii = rng.uniform(0, 1, size=[10]) + + rr.log("test1", rr.Points3D(positions, colors=colors, radii=radii)) + rr.log("test2", rr.Points2D(positions[:, :2], colors=colors, radii=radii))