fix(viewer): preserve follow camera across layer toggles

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 17:14:32 +03:00
parent a7babf60db
commit ae2ce055c8
7 changed files with 292 additions and 56 deletions

View File

@ -23,8 +23,8 @@ const vendorRuntime = [
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"), packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"), vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35", publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
previousNodedcSha256: "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da", previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2",
nodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2", nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469",
}, },
{ {
label: "wasm JavaScript glue", label: "wasm JavaScript glue",

View File

@ -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")); const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
assert.equal(manifest.version, "0.34.1"); assert.equal(manifest.version, "0.34.1");
const expectedWasm = "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2"; const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339"; const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm); 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(); 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"); const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
assert.match(patch, /fn pointer_ray_direction/); 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, /orbital_navigation_speed_floor_tracks_scene_scale/);
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/); assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/); 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/); assert.match(patch, /previous_picking_result/);
}); });

View File

@ -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 - Mission Core's explicit `/world/sensor_pose` orbital follow keeps the current
eye radius and orientation when enabled, remains attached through manual eye radius and orientation when enabled, remains attached through manual
orbit and zoom, and releases only when the operator toggles follow off; 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. - first-person movement, panning and Rerun's zoom-out cap are unchanged.
## Source identity ## Source identity
@ -33,6 +36,8 @@ This directory contains the audited Mission Core camera-controller override for
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2` `sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2`
- Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39` - Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39`
- Build date: `2026-07-23` (`Europe/Moscow`) - 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 ## Reproduction
@ -60,13 +65,14 @@ generated `re_viewer.js` is transformed with
| Artifact | SHA-256 | | Artifact | SHA-256 |
| --- | --- | | --- | --- |
| `re_viewer_bg.nodedc.wasm` | `6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2` | | `re_viewer_bg.nodedc.wasm` | `ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469` |
| raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` | | raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` |
| `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` | | `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 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. smoke test completed successfully, including the `externref` table-growth step.
The Mission Core Node suite also checks both artifact hashes and verifies that 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 every `wasm.*` reference in the JavaScript glue exists in the paired WASM

View File

@ -1,12 +1,12 @@
diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs 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 --- a/crates/viewer/re_view_spatial/src/eye.rs
+++ b/crates/viewer/re_view_spatial/src/eye.rs +++ b/crates/viewer/re_view_spatial/src/eye.rs
@@ -207,0 +208,3 @@ pub struct EyeState { @@ -207,0 +208,3 @@ pub struct EyeState {
+ /// World-space point selected when the current orbital drag started. + /// World-space point selected when the current orbital drag started.
+ orbit_drag_anchor: Option<Vec3>, + orbit_drag_anchor: Option<Vec3>,
+ +
@@ -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 +/// NODE.DC's operator follow mode is explicitly toggled outside the embedded
+/// viewer. Unlike Rerun's transient entity tracking, manual orbit/zoom must +/// viewer. Unlike Rerun's transient entity tracking, manual orbit/zoom must
+/// therefore keep this rig pivot attached until the operator toggles it off. +/// 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) + && 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<f32>,
+ 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 mut rot = self.rotation();
+ let rot = self.rotation_after_delta(delta); + 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); + 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 { + fn rotation_after_delta(&self, delta: egui::Vec2) -> Quat {
+ let mut rot = self.rotation(); + let mut rot = self.rotation();
@@ -452 +472,2 @@ impl EyeController { @@ -452 +509,2 @@ impl EyeController {
- rot = rot.normalize(); - rot = rot.normalize();
+ rot.normalize() + rot.normalize()
+ } + }
@@ -454 +475,13 @@ impl EyeController { @@ -454 +512,13 @@ impl EyeController {
- self.apply_rotation_and_radius(rot, radius); - self.apply_rotation_and_radius(rot, radius);
+ /// Rotate around the point selected under the pointer without moving that + /// Rotate around the point selected under the pointer without moving that
+ /// point on screen. + /// point on screen.
@ -48,7 +85,7 @@ index e59b311..77ac8d7 100644
+ fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) { + fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
+ let sensitivity = 0.004; + let sensitivity = 0.004;
+ self.rotate_radians_around_anchor(sensitivity * delta, anchor); + 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, response: &egui::Response, drag_threshold: f32) {
+ fn handle_drag( + fn handle_drag(
+ &mut self, + &mut self,
@ -60,7 +97,7 @@ index e59b311..77ac8d7 100644
+ if !response.dragged_by(ROTATE3D_BUTTON) { + if !response.dragged_by(ROTATE3D_BUTTON) {
+ eye_state.orbit_drag_anchor = None; + eye_state.orbit_drag_anchor = None;
+ } + }
@@ -503 +545,15 @@ impl EyeController { @@ -503 +582,15 @@ impl EyeController {
- self.rotate(response.drag_delta()); - self.rotate(response.drag_delta());
+ if self.kind == Eye3DKind::Orbital { + if self.kind == Eye3DKind::Orbital {
+ let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| { + let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| {
@ -77,7 +114,7 @@ index e59b311..77ac8d7 100644
+ } else { + } else {
+ self.rotate(response.drag_delta()); + 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. + /// Returns the world-space ray under the pointer.
+ /// + ///
+ /// Keeping this calculation local to the eye controller lets orbital zoom use 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; + 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, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
+ fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) { + fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) {
+ let egui_ctx = &response.ctx; + 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); - let radius = self.pos.distance(self.look_target);
- -
@@ -534,0 +676 @@ impl EyeController { @@ -534,0 +713 @@ impl EyeController {
+ let radius = self.radius(); + 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); - 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 - // 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(); + let pointer = response.ctx.pointer_latest_pos();
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer); + 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<Vec3>, + pointer_space_position: Option<Vec3>,
@@ -671,0 +806,5 @@ impl EyeController { @@ -671,0 +843,5 @@ impl EyeController {
+ if self.kind == Eye3DKind::Orbital { + if self.kind == Eye3DKind::Orbital {
+ self.speed = self + self.speed = self
+ .speed + .speed
+ .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64); + .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(response, drag_threshold);
+ self.handle_drag(eye_state, response, drag_threshold, pointer_space_position); + 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.ctx, scene_bounding_box);
+ self.handle_zoom(response, 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. +/// 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 +/// 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) + (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK)
+} +}
+ +
@@ -773,0 +931 @@ impl EyeState { @@ -773,0 +968 @@ impl EyeState {
+ pointer_space_position: Option<Vec3>, + pointer_space_position: Option<Vec3>,
@@ -816,0 +975 @@ impl EyeState { @@ -775 +970 @@ impl EyeState {
- ) -> Result<Eye, ViewPropertyQueryError> {
+ ) -> Result<(Eye, bool), ViewPropertyQueryError> {
@@ -777,0 +973,25 @@ impl EyeState {
+ let tracking_entity = eye_property
+ .component_or_empty::<re_sdk_types::components::EntityPath>(
+ 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::<Position3D>(EyeControls3D::descriptor_position().component)?
+ .is_some();
+ let has_blueprint_look_target = eye_property
+ .component_or_empty::<Position3D>(
+ 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::<re_sdk_types::components::EntityPath>(
- EyeControls3D::descriptor_tracking_entity().component,
- )?
- .filter(|tracking_entity| !tracking_entity.is_empty());
-
@@ -816,0 +1031 @@ impl EyeState {
+ pointer_space_position, + 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 = + let persistent_orbit_tracking =
+ keeps_orbital_tracking_after_interaction(eye_controller.kind, &tracking_entity); + keeps_orbital_tracking_after_interaction(eye_controller.kind, &tracking_entity);
@@ -946 +1107 @@ impl EyeState { @@ -946 +1163 @@ impl EyeState {
- if new_tracking { - if new_tracking {
+ if new_tracking && !persistent_orbit_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 {
+ if eye_controller.did_interact + if eye_controller.did_interact
+ && did_eye_orbit_center_change + && did_eye_orbit_center_change
+ && !persistent_orbit_tracking + && !persistent_orbit_tracking
+ { + {
@@ -1189,0 +1354 @@ impl EyeState { @@ -1189,0 +1410 @@ impl EyeState {
+ pointer_space_position: Option<Vec3>, + pointer_space_position: Option<Vec3>,
@@ -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, + pointer_space_position,
@@ -1251,0 +1418,145 @@ impl EyeState { @@ -1210,3 +1432,6 @@ impl EyeState {
- if eye_property
- .component_or_empty::<Position3D>(EyeControls3D::descriptor_position().component)?
- .is_none()
+ if !persistent_orbit_tracking
+ && eye_property
+ .component_or_empty::<Position3D>(
+ EyeControls3D::descriptor_position().component,
+ )?
+ .is_none()
@@ -1251,0 +1477,196 @@ impl EyeState {
+ +
+#[cfg(test)] +#[cfg(test)]
+mod tests { +mod tests {
@ -385,6 +477,57 @@ index e59b311..77ac8d7 100644
+ &other + &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 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 index 56257a8..07498ab 100644

Binary file not shown.

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from threading import Lock from threading import Lock
from typing import Any, Literal from typing import Any, Literal
@ -62,6 +63,7 @@ class _RecordedBlueprintStream:
self.view_reset_generation = view_reset_generation self.view_reset_generation = view_reset_generation
self._lock = Lock() self._lock = Lock()
self._sequence = 0 self._sequence = 0
self._follow_trajectory: bool | None = None
self._closed = False self._closed = False
native = bindings.new_blueprint( native = bindings.new_blueprint(
application_id=application_id, application_id=application_id,
@ -78,10 +80,20 @@ class _RecordedBlueprintStream:
) )
self._transport = rr.binary_stream(self._transport_recording) 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: with self._lock:
if self._closed: if self._closed:
raise RecordedBlueprintError("stable blueprint stream is 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( self._blueprint_recording.set_time(
"blueprint", "blueprint",
sequence=self._sequence, sequence=self._sequence,
@ -98,6 +110,7 @@ class _RecordedBlueprintStream:
payload = self._transport.read(flush=True, flush_timeout_sec=5.0) payload = self._transport.read(flush=True, flush_timeout_sec=5.0)
if not payload: if not payload:
raise RecordedBlueprintError("stable blueprint stream produced no data") raise RecordedBlueprintError("stable blueprint stream produced no data")
self._follow_trajectory = follow_trajectory
return payload return payload
def close(self) -> None: def close(self) -> None:
@ -156,6 +169,7 @@ def recorded_blueprint(
show_segmentation: bool = False, show_segmentation: bool = False,
show_cuboids_3d: bool = False, show_cuboids_3d: bool = False,
follow_trajectory: bool = False, follow_trajectory: bool = False,
update_eye_controls: bool = True,
) -> rrb.Blueprint: ) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds) accumulation = max(0.0, settings.accumulation_seconds)
time_ranges: list[rr.VisibleTimeRange] | None = None 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 # Keeping the view id stable preserves the current orbit offset when
# tracking is toggled. An explicit empty path clears tracking without # tracking is toggled. An explicit empty path clears tracking without
# overwriting the position/look-target saved by user interaction. # overwriting the position/look-target saved by user interaction.
eye_controls=rrb.EyeControls3D.from_fields( eye_controls=(
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, rrb.EyeControls3D.from_fields(
tracking_entity="/world/sensor_pose" if follow_trajectory else "", 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 = ( spatial_view.id = (
@ -274,9 +292,13 @@ def recorded_blueprint(
# native cloud from /world/points. # native cloud from /world/points.
"/world/perception/lidar": rrb.EntityBehavior(visible=False), "/world/perception/lidar": rrb.EntityBehavior(visible=False),
}, },
eye_controls=rrb.EyeControls3D.from_fields( eye_controls=(
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None, rrb.EyeControls3D.from_fields(
tracking_entity="/world/sensor_pose" if follow_trajectory else "", 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 = ( perception_3d_view.id = (
@ -385,17 +407,20 @@ def recorded_blueprint_rrd(
) -> bytes: ) -> bytes:
"""Serialize a bounded active blueprint update without recorded data.""" """Serialize a bounded active blueprint update without recorded data."""
blueprint = recorded_blueprint( def build_blueprint(update_eye_controls: bool) -> rrb.Blueprint:
settings, return recorded_blueprint(
include_initial_playback_state=False, settings,
active_view=active_view, include_initial_playback_state=False,
view_reset_generation=view_reset_generation, active_view=active_view,
unified_perception=unified_perception, view_reset_generation=view_reset_generation,
show_detections_2d=show_detections_2d, unified_perception=unified_perception,
show_segmentation=show_segmentation, show_detections_2d=show_detections_2d,
show_cuboids_3d=show_cuboids_3d, show_segmentation=show_segmentation,
follow_trajectory=follow_trajectory, show_cuboids_3d=show_cuboids_3d,
) follow_trajectory=follow_trajectory,
update_eye_controls=update_eye_controls,
)
payload: bytes | None payload: bytes | None
if blueprint_session_id is not None: if blueprint_session_id is not None:
try: try:
@ -404,12 +429,16 @@ def recorded_blueprint_rrd(
recording_id=recording_id, recording_id=recording_id,
blueprint_session_id=blueprint_session_id, blueprint_session_id=blueprint_session_id,
view_reset_generation=view_reset_generation, view_reset_generation=view_reset_generation,
).render(blueprint) ).render(
build_blueprint,
follow_trajectory=follow_trajectory,
)
except RecordedBlueprintError: except RecordedBlueprintError:
raise raise
except Exception as exc: except Exception as exc:
raise RecordedBlueprintError("failed to serialize stable recorded blueprint") from exc raise RecordedBlueprintError("failed to serialize stable recorded blueprint") from exc
else: else:
blueprint = build_blueprint(True)
recording = rr.RecordingStream( recording = rr.RecordingStream(
application_id, application_id,
recording_id=recording_id, recording_id=recording_id,

View File

@ -465,6 +465,12 @@ def test_recorded_follow_mode_tracks_sensor_pose_with_the_same_orbital_eye() ->
include_initial_playback_state=False, include_initial_playback_state=False,
follow_trajectory=True, 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] normal_view = normal.root_container.contents[0]
followed_view = followed.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_view.id == RECORDED_SPATIAL_VIEW_ID
assert followed_again.root_container.contents[0].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 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 == { assert normal_components == {
"EyeControls3D:tracking_entity": [""], "EyeControls3D:tracking_entity": [""],
} }
@ -495,7 +503,9 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
activations: list[tuple[object, bool, bool]] = [] activations: list[tuple[object, bool, bool]] = []
eye_control_updates: list[bool] = []
send_blueprint = viewer_recorded_module.bindings.send_blueprint send_blueprint = viewer_recorded_module.bindings.send_blueprint
make_blueprint = viewer_recorded_module.recorded_blueprint
def capture_activation( def capture_activation(
storage: object, storage: object,
@ -511,6 +521,16 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
"send_blueprint", "send_blueprint",
capture_activation, 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 session_id = "b" * 32
initial = viewer_recorded_blueprint_rrd( initial = viewer_recorded_blueprint_rrd(
RerunSceneSettings(accumulation_seconds=12.0), RerunSceneSettings(accumulation_seconds=12.0),
@ -527,6 +547,26 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
show_segmentation=True, show_segmentation=True,
show_cuboids_3d=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( reset = viewer_recorded_blueprint_rrd(
RerunSceneSettings(accumulation_seconds=12.0), RerunSceneSettings(accumulation_seconds=12.0),
recording_id="stable-camera", recording_id="stable-camera",
@ -536,25 +576,37 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
show_detections_2d=True, show_detections_2d=True,
show_segmentation=True, show_segmentation=True,
show_cuboids_3d=True, show_cuboids_3d=True,
follow_trajectory=True,
) )
store_pattern = rb"rec_[0-9a-f]{32}" store_pattern = rb"rec_[0-9a-f]{32}"
initial_store_ids = set(re.findall(store_pattern, initial)) initial_store_ids = set(re.findall(store_pattern, initial))
layer_store_ids = set(re.findall(store_pattern, layers_enabled)) 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)) reset_store_ids = set(re.findall(store_pattern, reset))
assert len(initial_store_ids) == 1 assert len(initial_store_ids) == 1
assert layer_store_ids == initial_store_ids 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 len(reset_store_ids) == 1
assert reset_store_ids.isdisjoint(initial_store_ids) assert reset_store_ids.isdisjoint(initial_store_ids)
assert eye_control_updates == [True, False, True, False, True]
assert [activation[1:] for activation in activations] == [ assert [activation[1:] for activation in activations] == [
(True, False), (True, False),
(True, False), (True, False),
(True, False), (True, False),
(True, False),
(True, False),
] ]
assert activations[0][0] is activations[1][0] 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(initial) < 350_000
assert len(layers_enabled) < 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 assert len(reset) < 350_000