From ae2ce055c8150c52b4f32308b36c2d65eef0df75 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 23 Jul 2026 17:14:32 +0300 Subject: [PATCH] fix(viewer): preserve follow camera across layer toggles --- .../scripts/patch-rerun-web-viewer.mjs | 4 +- .../test/rerunWebViewerVendor.test.mjs | 10 +- .../rerun-web-viewer-0.34.1/BUILD_INFO.md | 12 +- .../NODEDC_ZOOM_TO_CURSOR.patch | 197 +++++++++++++++--- .../re_viewer_bg.nodedc.wasm | 4 +- src/k1link/viewer/recorded.py | 67 ++++-- tests/test_rrd_export.py | 54 ++++- 7 files changed, 292 insertions(+), 56 deletions(-) diff --git a/apps/control-station/scripts/patch-rerun-web-viewer.mjs b/apps/control-station/scripts/patch-rerun-web-viewer.mjs index b91a0ca..f7c3738 100644 --- a/apps/control-station/scripts/patch-rerun-web-viewer.mjs +++ b/apps/control-station/scripts/patch-rerun-web-viewer.mjs @@ -23,8 +23,8 @@ const vendorRuntime = [ packagePath: resolve(packageRoot, "re_viewer_bg.wasm"), vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"), publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35", - previousNodedcSha256: "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da", - nodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2", + previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2", + nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469", }, { label: "wasm JavaScript glue", diff --git a/apps/control-station/test/rerunWebViewerVendor.test.mjs b/apps/control-station/test/rerunWebViewerVendor.test.mjs index 7184c43..96c2722 100644 --- a/apps/control-station/test/rerunWebViewerVendor.test.mjs +++ b/apps/control-station/test/rerunWebViewerVendor.test.mjs @@ -17,7 +17,7 @@ test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => { const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); assert.equal(manifest.version, "0.34.1"); - const expectedWasm = "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2"; + const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469"; const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339"; assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm); @@ -55,7 +55,7 @@ test("custom Rerun WASM initializes and grows its externref table", () => { runtime.deinit(); }); -test("source patch carries pointer navigation, persistent follow, and geometry tests", () => { +test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => { const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8"); assert.match(patch, /fn pointer_ray_direction/); @@ -71,5 +71,11 @@ test("source patch carries pointer navigation, persistent follow, and geometry t assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/); assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/); assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/); + assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/); + assert.match( + patch, + /persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/, + ); + assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/); assert.match(patch, /previous_picking_result/); }); diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md b/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md index ca5c545..92c2033 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md @@ -16,6 +16,9 @@ This directory contains the audited Mission Core camera-controller override for - Mission Core's explicit `/world/sensor_pose` orbital follow keeps the current eye radius and orientation when enabled, remains attached through manual orbit and zoom, and releases only when the operator toggles follow off; +- repeated server-side blueprint activation for 2D detections, segmentation, + and 3D cuboids restores the last rendered operator eye instead of applying + Rerun's scene-bounds fallback, so layer toggles cannot reset the camera; - first-person movement, panning and Rerun's zoom-out cap are unchanged. ## Source identity @@ -33,6 +36,8 @@ This directory contains the audited Mission Core camera-controller override for `sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2` - Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39` - Build date: `2026-07-23` (`Europe/Moscow`) +- Measured build stages: Rust to WASM `296.7 s`, JavaScript bindings + `8.5 s`, Binaryen `wasm-opt -O2` `1244.2 s` ## Reproduction @@ -60,13 +65,14 @@ generated `re_viewer.js` is transformed with | Artifact | SHA-256 | | --- | --- | -| `re_viewer_bg.nodedc.wasm` | `6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2` | +| `re_viewer_bg.nodedc.wasm` | `ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469` | | raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` | | `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` | -The focused Rust suite completed with `8 passed, 0 failed`, including the +The focused Rust suite completed with `10 passed, 0 failed`, including the orbit-to-dolly boundary, pointer-anchored rotation and scene-scale speed-floor -cases, plus persistent rig-orbit tracking. A Node `initSync` +cases, persistent rig-orbit tracking, camera continuity across blueprint +reactivation, and explicit reset precedence. A Node `initSync` smoke test completed successfully, including the `externref` table-growth step. The Mission Core Node suite also checks both artifact hashes and verifies that every `wasm.*` reference in the JavaScript glue exists in the paired WASM diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch b/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch index 9854511..48d4139 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch @@ -1,12 +1,12 @@ diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs -index e59b311..77ac8d7 100644 +index e59b311..b98653a 100644 --- a/crates/viewer/re_view_spatial/src/eye.rs +++ b/crates/viewer/re_view_spatial/src/eye.rs @@ -207,0 +208,3 @@ pub struct EyeState { + /// World-space point selected when the current orbital drag started. + orbit_drag_anchor: Option, + -@@ -245,0 +249,13 @@ pub(crate) struct EyeController { +@@ -245,0 +249,50 @@ pub(crate) struct EyeController { +/// NODE.DC's operator follow mode is explicitly toggled outside the embedded +/// viewer. Unlike Rerun's transient entity tracking, manual orbit/zoom must +/// therefore keep this rig pivot attached until the operator toggles it off. @@ -20,20 +20,57 @@ index e59b311..77ac8d7 100644 + && tracking_entity == &EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY) +} + -@@ -434 +450 @@ impl EyeController { ++/// Keeps the last rendered operator eye when a server-side blueprint update ++/// reactivates the same persistent follow view without explicit pose fields. ++/// ++/// Rerun normally falls back to the scene bounding box when a blueprint has no ++/// position or look target. Mission Core layer toggles intentionally omit those ++/// fields so that the browser-owned eye remains authoritative. ++fn restore_persistent_orbit_eye_after_blueprint_update( ++ eye_controller: &mut EyeController, ++ previous_eye: Option<&Eye>, ++ previous_orbit_radius: Option, ++ tracking_entity: Option<&EntityPath>, ++ has_blueprint_position: bool, ++ has_blueprint_look_target: bool, ++) -> bool { ++ let Some(tracking_entity) = tracking_entity else { ++ return false; ++ }; ++ let persistent = ++ keeps_orbital_tracking_after_interaction(eye_controller.kind, tracking_entity); ++ if persistent ++ && !has_blueprint_position ++ && !has_blueprint_look_target ++ && let (Some(previous_eye), Some(previous_orbit_radius)) = ++ (previous_eye, previous_orbit_radius) ++ { ++ let pos = previous_eye.pos_in_world(); ++ let radius = previous_orbit_radius.max(EyeController::MIN_ORBIT_DISTANCE); ++ eye_controller.pos = pos; ++ eye_controller.look_target = pos + previous_eye.forward_in_world() * radius; ++ eye_controller.eye_up = previous_eye ++ .world_from_rub_view ++ .transform_vector3(Vec3::Y); ++ eye_controller.fov_y = previous_eye.fov_y; ++ } ++ persistent ++} ++ +@@ -434 +487 @@ impl EyeController { - let mut rot = self.rotation(); + let rot = self.rotation_after_delta(delta); -@@ -435,0 +452,2 @@ impl EyeController { +@@ -435,0 +489,2 @@ impl EyeController { + self.apply_rotation_and_radius(rot, radius); + } -@@ -436,0 +455,2 @@ impl EyeController { +@@ -436,0 +492,2 @@ impl EyeController { + fn rotation_after_delta(&self, delta: egui::Vec2) -> Quat { + let mut rot = self.rotation(); -@@ -452 +472,2 @@ impl EyeController { +@@ -452 +509,2 @@ impl EyeController { - rot = rot.normalize(); + rot.normalize() + } -@@ -454 +475,13 @@ impl EyeController { +@@ -454 +512,13 @@ impl EyeController { - self.apply_rotation_and_radius(rot, radius); + /// Rotate around the point selected under the pointer without moving that + /// point on screen. @@ -48,7 +85,7 @@ index e59b311..77ac8d7 100644 + fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) { + let sensitivity = 0.004; + self.rotate_radians_around_anchor(sensitivity * delta, anchor); -@@ -491 +524,10 @@ impl EyeController { +@@ -491 +561,10 @@ impl EyeController { - fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) { + fn handle_drag( + &mut self, @@ -60,7 +97,7 @@ index e59b311..77ac8d7 100644 + if !response.dragged_by(ROTATE3D_BUTTON) { + eye_state.orbit_drag_anchor = None; + } -@@ -503 +545,15 @@ impl EyeController { +@@ -503 +582,15 @@ impl EyeController { - self.rotate(response.drag_delta()); + if self.kind == Eye3DKind::Orbital { + let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| { @@ -77,7 +114,7 @@ index e59b311..77ac8d7 100644 + } else { + self.rotate(response.drag_delta()); + } -@@ -514,0 +571,86 @@ impl EyeController { +@@ -514,0 +608,86 @@ impl EyeController { + /// Returns the world-space ray under the pointer. + /// + /// Keeping this calculation local to the eye controller lets orbital zoom use the pointer @@ -164,16 +201,16 @@ index e59b311..77ac8d7 100644 + self.did_interact = true; + } + -@@ -516 +658,2 @@ impl EyeController { +@@ -516 +695,2 @@ impl EyeController { - fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) { + fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) { + let egui_ctx = &response.ctx; -@@ -531,2 +673,0 @@ impl EyeController { +@@ -531,2 +710,0 @@ impl EyeController { - let radius = self.pos.distance(self.look_target); - -@@ -534,0 +676 @@ impl EyeController { +@@ -534,0 +713 @@ impl EyeController { + let radius = self.radius(); -@@ -536,11 +678,2 @@ impl EyeController { +@@ -536,11 +715,2 @@ impl EyeController { - let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius); - - // The user may be scrolling to move the camera closer, but are not realizing @@ -187,21 +224,21 @@ index e59b311..77ac8d7 100644 - } + let pointer = response.ctx.pointer_latest_pos(); + self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer); -@@ -669,0 +803 @@ impl EyeController { +@@ -669,0 +840 @@ impl EyeController { + pointer_space_position: Option, -@@ -671,0 +806,5 @@ impl EyeController { +@@ -671,0 +843,5 @@ impl EyeController { + if self.kind == Eye3DKind::Orbital { + self.speed = self + .speed + .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64); + } -@@ -687 +826 @@ impl EyeController { +@@ -687 +863 @@ impl EyeController { - self.handle_drag(response, drag_threshold); + self.handle_drag(eye_state, response, drag_threshold, pointer_space_position); -@@ -690 +829 @@ impl EyeController { +@@ -690 +866 @@ impl EyeController { - self.handle_zoom(&response.ctx, scene_bounding_box); + self.handle_zoom(response, scene_bounding_box); -@@ -728,0 +868,18 @@ fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 { +@@ -728,0 +905,18 @@ fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 { +/// Lower bound for free-flight and post-limit zoom speed in orbital mode. +/// +/// The default orbital speed is the current orbit radius. Close to the near @@ -220,27 +257,82 @@ index e59b311..77ac8d7 100644 + (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK) +} + -@@ -773,0 +931 @@ impl EyeState { +@@ -773,0 +968 @@ impl EyeState { + pointer_space_position: Option, -@@ -816,0 +975 @@ impl EyeState { +@@ -775 +970 @@ impl EyeState { +- ) -> Result { ++ ) -> Result<(Eye, bool), ViewPropertyQueryError> { +@@ -777,0 +973,25 @@ impl EyeState { ++ let tracking_entity = eye_property ++ .component_or_empty::( ++ EyeControls3D::descriptor_tracking_entity().component, ++ )? ++ .filter(|tracking_entity| !tracking_entity.is_empty()); ++ let tracking_entity_path = tracking_entity ++ .as_ref() ++ .map(|tracking_entity| EntityPath::from(tracking_entity.as_str())); ++ let has_blueprint_position = eye_property ++ .component_or_empty::(EyeControls3D::descriptor_position().component)? ++ .is_some(); ++ let has_blueprint_look_target = eye_property ++ .component_or_empty::( ++ EyeControls3D::descriptor_look_target().component, ++ )? ++ .is_some(); ++ let persistent_orbit_tracking = restore_persistent_orbit_eye_after_blueprint_update( ++ &mut eye_controller, ++ self.last_eye.as_ref(), ++ self.last_orbit_radius, ++ tracking_entity_path.as_ref(), ++ has_blueprint_position, ++ has_blueprint_look_target, ++ ); ++ +@@ -794,6 +1013,0 @@ impl EyeState { +- let tracking_entity = eye_property +- .component_or_empty::( +- EyeControls3D::descriptor_tracking_entity().component, +- )? +- .filter(|tracking_entity| !tracking_entity.is_empty()); +- +@@ -816,0 +1031 @@ impl EyeState { + pointer_space_position, -@@ -891,0 +1051,2 @@ impl EyeState { +@@ -865 +1080 @@ impl EyeState { +- return Ok(tracked_eye); ++ return Ok((tracked_eye, persistent_orbit_tracking)); +@@ -872 +1087 @@ impl EyeState { +- Ok(eye_controller.get_eye()) ++ Ok((eye_controller.get_eye(), persistent_orbit_tracking)) +@@ -891,0 +1107,2 @@ impl EyeState { + let persistent_orbit_tracking = + keeps_orbital_tracking_after_interaction(eye_controller.kind, &tracking_entity); -@@ -946 +1107 @@ impl EyeState { +@@ -946 +1163 @@ impl EyeState { - if new_tracking { + if new_tracking && !persistent_orbit_tracking { -@@ -998 +1159,4 @@ impl EyeState { +@@ -998 +1215,4 @@ impl EyeState { - if eye_controller.did_interact && did_eye_orbit_center_change { + if eye_controller.did_interact + && did_eye_orbit_center_change + && !persistent_orbit_tracking + { -@@ -1189,0 +1354 @@ impl EyeState { +@@ -1189,0 +1410 @@ impl EyeState { + pointer_space_position: Option, -@@ -1203,0 +1369 @@ impl EyeState { +@@ -1198 +1419 @@ impl EyeState { +- let target_eye = self.control_and_sync_with_blueprint( ++ let (target_eye, persistent_orbit_tracking) = self.control_and_sync_with_blueprint( +@@ -1203,0 +1425 @@ impl EyeState { + pointer_space_position, -@@ -1251,0 +1418,145 @@ impl EyeState { +@@ -1210,3 +1432,6 @@ impl EyeState { +- if eye_property +- .component_or_empty::(EyeControls3D::descriptor_position().component)? +- .is_none() ++ if !persistent_orbit_tracking ++ && eye_property ++ .component_or_empty::( ++ EyeControls3D::descriptor_position().component, ++ )? ++ .is_none() +@@ -1251,0 +1477,196 @@ impl EyeState { + +#[cfg(test)] +mod tests { @@ -385,6 +477,57 @@ index e59b311..77ac8d7 100644 + &other + )); + } ++ ++ #[test] ++ fn persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update() { ++ let previous_controller = ++ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0)); ++ let previous_eye = previous_controller.get_eye(); ++ let previous_radius = previous_controller.radius(); ++ let mut fallback_controller = ++ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO); ++ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY); ++ ++ assert!(restore_persistent_orbit_eye_after_blueprint_update( ++ &mut fallback_controller, ++ Some(&previous_eye), ++ Some(previous_radius), ++ Some(&rig), ++ false, ++ false, ++ )); ++ assert_vec3_close(fallback_controller.pos, previous_eye.pos_in_world()); ++ assert!((fallback_controller.radius() - previous_radius).abs() < 1.0e-5); ++ assert!( ++ fallback_controller ++ .fwd() ++ .dot(previous_eye.forward_in_world()) ++ > 0.99999 ++ ); ++ } ++ ++ #[test] ++ fn explicit_blueprint_pose_is_not_replaced_by_the_previous_eye() { ++ let previous_controller = ++ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0)); ++ let previous_eye = previous_controller.get_eye(); ++ let mut explicit_controller = ++ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO); ++ let explicit_pos = explicit_controller.pos; ++ let explicit_target = explicit_controller.look_target; ++ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY); ++ ++ assert!(restore_persistent_orbit_eye_after_blueprint_update( ++ &mut explicit_controller, ++ Some(&previous_eye), ++ Some(previous_controller.radius()), ++ Some(&rig), ++ true, ++ true, ++ )); ++ assert_vec3_close(explicit_controller.pos, explicit_pos); ++ assert_vec3_close(explicit_controller.look_target, explicit_target); ++ } +} diff --git a/crates/viewer/re_view_spatial/src/ui_3d.rs b/crates/viewer/re_view_spatial/src/ui_3d.rs index 56257a8..07498ab 100644 diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm b/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm index 12e9c19..6f5b860 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2 -size 48580358 +oid sha256:ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469 +size 48597749 diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py index e9f651c..ad6699a 100644 --- a/src/k1link/viewer/recorded.py +++ b/src/k1link/viewer/recorded.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import OrderedDict +from collections.abc import Callable from contextlib import suppress from threading import Lock from typing import Any, Literal @@ -62,6 +63,7 @@ class _RecordedBlueprintStream: self.view_reset_generation = view_reset_generation self._lock = Lock() self._sequence = 0 + self._follow_trajectory: bool | None = None self._closed = False native = bindings.new_blueprint( application_id=application_id, @@ -78,10 +80,20 @@ class _RecordedBlueprintStream: ) self._transport = rr.binary_stream(self._transport_recording) - def render(self, blueprint: rrb.Blueprint) -> bytes: + def render( + self, + blueprint_factory: Callable[[bool], rrb.Blueprint], + *, + follow_trajectory: bool, + ) -> bytes: with self._lock: if self._closed: raise RecordedBlueprintError("stable blueprint stream is closed") + update_eye_controls = ( + self._follow_trajectory is None + or self._follow_trajectory != follow_trajectory + ) + blueprint = blueprint_factory(update_eye_controls) self._blueprint_recording.set_time( "blueprint", sequence=self._sequence, @@ -98,6 +110,7 @@ class _RecordedBlueprintStream: payload = self._transport.read(flush=True, flush_timeout_sec=5.0) if not payload: raise RecordedBlueprintError("stable blueprint stream produced no data") + self._follow_trajectory = follow_trajectory return payload def close(self) -> None: @@ -156,6 +169,7 @@ def recorded_blueprint( show_segmentation: bool = False, show_cuboids_3d: bool = False, follow_trajectory: bool = False, + update_eye_controls: bool = True, ) -> rrb.Blueprint: accumulation = max(0.0, settings.accumulation_seconds) time_ranges: list[rr.VisibleTimeRange] | None = None @@ -218,9 +232,13 @@ def recorded_blueprint( # Keeping the view id stable preserves the current orbit offset when # tracking is toggled. An explicit empty path clears tracking without # overwriting the position/look-target saved by user interaction. - eye_controls=rrb.EyeControls3D.from_fields( - kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, - tracking_entity="/world/sensor_pose" if follow_trajectory else "", + eye_controls=( + rrb.EyeControls3D.from_fields( + kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, + tracking_entity="/world/sensor_pose" if follow_trajectory else "", + ) + if update_eye_controls + else None ), ) spatial_view.id = ( @@ -274,9 +292,13 @@ def recorded_blueprint( # native cloud from /world/points. "/world/perception/lidar": rrb.EntityBehavior(visible=False), }, - eye_controls=rrb.EyeControls3D.from_fields( - kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, - tracking_entity="/world/sensor_pose" if follow_trajectory else "", + eye_controls=( + rrb.EyeControls3D.from_fields( + kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, + tracking_entity="/world/sensor_pose" if follow_trajectory else "", + ) + if update_eye_controls + else None ), ) perception_3d_view.id = ( @@ -385,17 +407,20 @@ def recorded_blueprint_rrd( ) -> bytes: """Serialize a bounded active blueprint update without recorded data.""" - blueprint = recorded_blueprint( - settings, - include_initial_playback_state=False, - active_view=active_view, - view_reset_generation=view_reset_generation, - unified_perception=unified_perception, - show_detections_2d=show_detections_2d, - show_segmentation=show_segmentation, - show_cuboids_3d=show_cuboids_3d, - follow_trajectory=follow_trajectory, - ) + def build_blueprint(update_eye_controls: bool) -> rrb.Blueprint: + return recorded_blueprint( + settings, + include_initial_playback_state=False, + active_view=active_view, + view_reset_generation=view_reset_generation, + unified_perception=unified_perception, + show_detections_2d=show_detections_2d, + show_segmentation=show_segmentation, + show_cuboids_3d=show_cuboids_3d, + follow_trajectory=follow_trajectory, + update_eye_controls=update_eye_controls, + ) + payload: bytes | None if blueprint_session_id is not None: try: @@ -404,12 +429,16 @@ def recorded_blueprint_rrd( recording_id=recording_id, blueprint_session_id=blueprint_session_id, view_reset_generation=view_reset_generation, - ).render(blueprint) + ).render( + build_blueprint, + follow_trajectory=follow_trajectory, + ) except RecordedBlueprintError: raise except Exception as exc: raise RecordedBlueprintError("failed to serialize stable recorded blueprint") from exc else: + blueprint = build_blueprint(True) recording = rr.RecordingStream( application_id, recording_id=recording_id, diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py index 526168d..a5f1ffe 100644 --- a/tests/test_rrd_export.py +++ b/tests/test_rrd_export.py @@ -465,6 +465,12 @@ def test_recorded_follow_mode_tracks_sensor_pose_with_the_same_orbital_eye() -> include_initial_playback_state=False, follow_trajectory=True, ) + layer_only_update = viewer_recorded_blueprint( + RerunSceneSettings(show_grid=False), + include_initial_playback_state=False, + follow_trajectory=True, + update_eye_controls=False, + ) normal_view = normal.root_container.contents[0] followed_view = followed.root_container.contents[0] @@ -482,6 +488,8 @@ def test_recorded_follow_mode_tracks_sensor_pose_with_the_same_orbital_eye() -> assert followed_view.id == RECORDED_SPATIAL_VIEW_ID assert followed_again.root_container.contents[0].id == RECORDED_SPATIAL_VIEW_ID assert followed_view.id == normal_view.id + assert "EyeControls3D" not in layer_only_update.root_container.contents[0].properties + assert "EyeControls3D" not in layer_only_update.root_container.contents[2].properties assert normal_components == { "EyeControls3D:tracking_entity": [""], } @@ -495,7 +503,9 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset( monkeypatch: pytest.MonkeyPatch, ) -> None: activations: list[tuple[object, bool, bool]] = [] + eye_control_updates: list[bool] = [] send_blueprint = viewer_recorded_module.bindings.send_blueprint + make_blueprint = viewer_recorded_module.recorded_blueprint def capture_activation( storage: object, @@ -511,6 +521,16 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset( "send_blueprint", capture_activation, ) + + def capture_eye_control_update(*args: object, **kwargs: object) -> object: + eye_control_updates.append(bool(kwargs["update_eye_controls"])) + return make_blueprint(*args, **kwargs) + + monkeypatch.setattr( + viewer_recorded_module, + "recorded_blueprint", + capture_eye_control_update, + ) session_id = "b" * 32 initial = viewer_recorded_blueprint_rrd( RerunSceneSettings(accumulation_seconds=12.0), @@ -527,6 +547,26 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset( show_segmentation=True, show_cuboids_3d=True, ) + follow_enabled = viewer_recorded_blueprint_rrd( + RerunSceneSettings(accumulation_seconds=12.0), + recording_id="stable-camera", + blueprint_session_id=session_id, + unified_perception=True, + show_detections_2d=True, + show_segmentation=True, + show_cuboids_3d=True, + follow_trajectory=True, + ) + layer_disabled_while_following = viewer_recorded_blueprint_rrd( + RerunSceneSettings(accumulation_seconds=12.0), + recording_id="stable-camera", + blueprint_session_id=session_id, + unified_perception=True, + show_detections_2d=False, + show_segmentation=True, + show_cuboids_3d=True, + follow_trajectory=True, + ) reset = viewer_recorded_blueprint_rrd( RerunSceneSettings(accumulation_seconds=12.0), recording_id="stable-camera", @@ -536,25 +576,37 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset( show_detections_2d=True, show_segmentation=True, show_cuboids_3d=True, + follow_trajectory=True, ) store_pattern = rb"rec_[0-9a-f]{32}" initial_store_ids = set(re.findall(store_pattern, initial)) layer_store_ids = set(re.findall(store_pattern, layers_enabled)) + follow_store_ids = set(re.findall(store_pattern, follow_enabled)) + followed_layer_store_ids = set(re.findall(store_pattern, layer_disabled_while_following)) reset_store_ids = set(re.findall(store_pattern, reset)) assert len(initial_store_ids) == 1 assert layer_store_ids == initial_store_ids + assert follow_store_ids == initial_store_ids + assert followed_layer_store_ids == initial_store_ids assert len(reset_store_ids) == 1 assert reset_store_ids.isdisjoint(initial_store_ids) + assert eye_control_updates == [True, False, True, False, True] assert [activation[1:] for activation in activations] == [ (True, False), (True, False), (True, False), + (True, False), + (True, False), ] assert activations[0][0] is activations[1][0] - assert activations[2][0] is not activations[0][0] + assert activations[2][0] is activations[0][0] + assert activations[3][0] is activations[0][0] + assert activations[4][0] is not activations[0][0] assert len(initial) < 350_000 assert len(layers_enabled) < 350_000 + assert len(follow_enabled) < 350_000 + assert len(layer_disabled_while_following) < 350_000 assert len(reset) < 350_000