fix(viewer): preserve operator camera across AI layers
This commit is contained in:
@@ -1,37 +1,42 @@
|
||||
# NODE.DC Rerun web viewer 0.34.1
|
||||
|
||||
This directory contains the audited Mission Core camera-controller override for
|
||||
`@rerun-io/web-viewer` 0.34.1. It changes only the native orbital zoom behavior:
|
||||
`@rerun-io/web-viewer` 0.34.1. It changes only native spatial-camera behavior:
|
||||
|
||||
- the pointer ray selects an anchor on the current focus plane;
|
||||
- eye position and look target scale around that anchor, so the point under the
|
||||
cursor remains under the cursor;
|
||||
- orbital drag anchors to the picked world-space point under the initial cursor
|
||||
position, with the focus plane as an empty-space fallback;
|
||||
- after Rerun's `0.02 m` near-plane safety radius is reached, excess zoom becomes
|
||||
a cursor-directed dolly scaled by the scene's navigation speed instead of
|
||||
silently ignoring the wheel or moving by imperceptible millimeters;
|
||||
- first-person movement, orbit rotation, panning, WASD and Rerun's zoom-out cap
|
||||
are unchanged.
|
||||
- orbital navigation has a scene-scale speed floor, so WASD and post-limit
|
||||
dolly remain useful at the minimum radius;
|
||||
- first-person movement, panning and Rerun's zoom-out cap are unchanged.
|
||||
|
||||
## Source identity
|
||||
|
||||
- Upstream: `rerun-io/rerun`
|
||||
- Tag: `0.34.1`
|
||||
- Commit: `4efb18f17f6f0e41985cda99a2bdcd012febc8d5`
|
||||
- Patched file: `crates/viewer/re_view_spatial/src/eye.rs`
|
||||
- Patched files: `crates/viewer/re_view_spatial/src/eye.rs`,
|
||||
`crates/viewer/re_view_spatial/src/ui_3d.rs`
|
||||
- Patch: `NODEDC_ZOOM_TO_CURSOR.patch`
|
||||
- Rust: `1.92.0`
|
||||
- Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`)
|
||||
- Build image: `rust:1.92-bookworm`
|
||||
- Build image digest:
|
||||
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2`
|
||||
- Build date: `2026-07-22` (`Europe/Moscow`)
|
||||
- Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39`
|
||||
- Build date: `2026-07-23` (`Europe/Moscow`)
|
||||
|
||||
## Reproduction
|
||||
|
||||
Apply the patch to the exact upstream commit, then run Rerun's own builder:
|
||||
|
||||
```sh
|
||||
git apply NODEDC_ZOOM_TO_CURSOR.patch
|
||||
git apply --unidiff-zero NODEDC_ZOOM_TO_CURSOR.patch
|
||||
cargo test -p re_view_spatial --lib eye::tests:: -- --nocapture
|
||||
cargo run -p re_dev_tools -- build-web-viewer \
|
||||
--release -g \
|
||||
@@ -52,12 +57,13 @@ generated `re_viewer.js` is transformed with
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `re_viewer_bg.nodedc.wasm` | `38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb` |
|
||||
| `re_viewer_bg.nodedc.wasm` | `e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da` |
|
||||
| raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` |
|
||||
| `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` |
|
||||
|
||||
The focused Rust suite completed with `5 passed, 0 failed`, including the
|
||||
orbit-to-dolly boundary case. A Node `initSync`
|
||||
The focused Rust suite completed with `7 passed, 0 failed`, including the
|
||||
orbit-to-dolly boundary, pointer-anchored rotation and scene-scale speed-floor
|
||||
cases. 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
|
||||
|
||||
+186
-62
@@ -1,11 +1,69 @@
|
||||
diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
index e59b311..3f33259 100644
|
||||
index e59b311..7159aab 100644
|
||||
--- a/crates/viewer/re_view_spatial/src/eye.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
@@ -512,8 +512,85 @@ impl EyeController {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,0 +208,3 @@ pub struct EyeState {
|
||||
+ /// World-space point selected when the current orbital drag started.
|
||||
+ orbit_drag_anchor: Option<Vec3>,
|
||||
+
|
||||
@@ -434 +437 @@ impl EyeController {
|
||||
- let mut rot = self.rotation();
|
||||
+ let rot = self.rotation_after_delta(delta);
|
||||
@@ -435,0 +439,2 @@ impl EyeController {
|
||||
+ self.apply_rotation_and_radius(rot, radius);
|
||||
+ }
|
||||
@@ -436,0 +442,2 @@ impl EyeController {
|
||||
+ fn rotation_after_delta(&self, delta: egui::Vec2) -> Quat {
|
||||
+ let mut rot = self.rotation();
|
||||
@@ -452 +459,2 @@ impl EyeController {
|
||||
- rot = rot.normalize();
|
||||
+ rot.normalize()
|
||||
+ }
|
||||
@@ -454 +462,13 @@ impl EyeController {
|
||||
- self.apply_rotation_and_radius(rot, radius);
|
||||
+ /// Rotate around the point selected under the pointer without moving that
|
||||
+ /// point on screen.
|
||||
+ fn rotate_radians_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
|
||||
+ let old_rotation = self.rotation();
|
||||
+ let new_rotation = self.rotation_after_delta(delta);
|
||||
+ let world_delta = new_rotation * old_rotation.inverse();
|
||||
+ self.pos = anchor + world_delta * (self.pos - anchor);
|
||||
+ self.look_target = anchor + world_delta * (self.look_target - anchor);
|
||||
+ }
|
||||
+
|
||||
+ fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
|
||||
+ let sensitivity = 0.004;
|
||||
+ self.rotate_radians_around_anchor(sensitivity * delta, anchor);
|
||||
@@ -491 +511,10 @@ impl EyeController {
|
||||
- fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
|
||||
+ fn handle_drag(
|
||||
+ &mut self,
|
||||
+ eye_state: &mut EyeState,
|
||||
+ response: &egui::Response,
|
||||
+ drag_threshold: f32,
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
+ ) {
|
||||
+ if !response.dragged_by(ROTATE3D_BUTTON) {
|
||||
+ eye_state.orbit_drag_anchor = None;
|
||||
+ }
|
||||
@@ -503 +532,15 @@ impl EyeController {
|
||||
- self.rotate(response.drag_delta());
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| {
|
||||
+ pointer_space_position
|
||||
+ .filter(|position| position.is_finite())
|
||||
+ .or_else(|| {
|
||||
+ response.ctx.pointer_latest_pos().and_then(|pointer| {
|
||||
+ self.pointer_focus_plane_anchor(response.rect, pointer)
|
||||
+ })
|
||||
+ })
|
||||
+ .unwrap_or(self.look_target)
|
||||
+ });
|
||||
+ self.rotate_around_anchor(response.drag_delta(), anchor);
|
||||
+ } else {
|
||||
+ self.rotate(response.drag_delta());
|
||||
+ }
|
||||
@@ -514,0 +558,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
|
||||
@@ -18,14 +76,24 @@ index e59b311..3f33259 100644
|
||||
+ let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y);
|
||||
+ let aspect_ratio = rect.width() / rect.height();
|
||||
+ let focal_scale = (fov_y * 0.5).tan();
|
||||
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0)
|
||||
+ * focal_scale
|
||||
+ * aspect_ratio;
|
||||
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0) * focal_scale * aspect_ratio;
|
||||
+ let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale;
|
||||
+
|
||||
+ (self.rotation() * vec3(x, y, -1.0)).try_normalize()
|
||||
+ }
|
||||
+
|
||||
+ /// Intersect the pointer ray with the plane through the current look
|
||||
+ /// target. Empty-space drags then have a deterministic pivot even when GPU
|
||||
+ /// picking did not hit a rendered point.
|
||||
+ fn pointer_focus_plane_anchor(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
|
||||
+ let ray_direction = self.pointer_ray_direction(rect, pointer)?;
|
||||
+ let denominator = ray_direction.dot(self.fwd());
|
||||
+ if denominator <= 1.0e-4 {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(self.pos + ray_direction * (self.radius() / denominator))
|
||||
+ }
|
||||
+
|
||||
+ /// Zoom an orbital eye around the point under the pointer on the current focus plane.
|
||||
+ ///
|
||||
+ /// The position and look target are scaled around the same anchor. This preserves the
|
||||
@@ -48,32 +116,32 @@ index e59b311..3f33259 100644
|
||||
+ let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
+ let scale = new_radius / radius;
|
||||
+
|
||||
+ let ray_direction = pointer.and_then(|pointer| self.pointer_ray_direction(rect, pointer));
|
||||
+ if let Some(ray_direction) = ray_direction {
|
||||
+ let forward = self.fwd();
|
||||
+ let denominator = ray_direction.dot(forward);
|
||||
+ if denominator > 1.0e-4 {
|
||||
+ let anchor = self.pos + ray_direction * (radius / denominator);
|
||||
+ self.pos = anchor + (self.pos - anchor) * scale;
|
||||
+ self.look_target = anchor + (self.look_target - anchor) * scale;
|
||||
+ let pointer_ray = pointer.and_then(|pointer| {
|
||||
+ Some((
|
||||
+ self.pointer_ray_direction(rect, pointer)?,
|
||||
+ self.pointer_focus_plane_anchor(rect, pointer)?,
|
||||
+ ))
|
||||
+ });
|
||||
+ if let Some((ray_direction, anchor)) = pointer_ray {
|
||||
+ self.pos = anchor + (self.pos - anchor) * scale;
|
||||
+ self.look_target = anchor + (self.look_target - anchor) * scale;
|
||||
+
|
||||
+ if requested_radius < Self::MIN_ORBIT_DISTANCE {
|
||||
+ // Shrinking the remaining 2 cm orbit radius consumes only part of this input.
|
||||
+ // Hand the logarithmic remainder to the same scene-scaled speed used by WASD
|
||||
+ // and first-person scroll. Basing this on the near-plane remainder itself made
|
||||
+ // each wheel event move by millimeters and felt indistinguishable from a hard
|
||||
+ // zoom limit on building- and map-scale recordings.
|
||||
+ let orbit_zoom_factor = (radius / Self::MIN_ORBIT_DISTANCE).max(1.0);
|
||||
+ let remaining_zoom_factor = zoom_factor / orbit_zoom_factor;
|
||||
+ if remaining_zoom_factor > 1.0 && remaining_zoom_factor.is_finite() {
|
||||
+ let dolly = remaining_zoom_factor.ln() * self.speed as f32;
|
||||
+ self.pos += ray_direction * dolly;
|
||||
+ self.look_target += ray_direction * dolly;
|
||||
+ }
|
||||
+ if requested_radius < Self::MIN_ORBIT_DISTANCE {
|
||||
+ // Shrinking the remaining 2 cm orbit radius consumes only part of this input.
|
||||
+ // Hand the logarithmic remainder to the same scene-scaled speed used by WASD
|
||||
+ // and first-person scroll. Basing this on the near-plane remainder itself made
|
||||
+ // each wheel event move by millimeters and felt indistinguishable from a hard
|
||||
+ // zoom limit on building- and map-scale recordings.
|
||||
+ let orbit_zoom_factor = (radius / Self::MIN_ORBIT_DISTANCE).max(1.0);
|
||||
+ let remaining_zoom_factor = zoom_factor / orbit_zoom_factor;
|
||||
+ if remaining_zoom_factor > 1.0 && remaining_zoom_factor.is_finite() {
|
||||
+ let dolly = remaining_zoom_factor.ln() * self.speed as f32;
|
||||
+ self.pos += ray_direction * dolly;
|
||||
+ self.look_target += ray_direction * dolly;
|
||||
+ }
|
||||
+ self.did_interact = true;
|
||||
+ return;
|
||||
+ }
|
||||
+ self.did_interact = true;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
|
||||
@@ -82,23 +150,16 @@ index e59b311..3f33259 100644
|
||||
+ self.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
/// Handle zoom/scroll input.
|
||||
@@ -516 +645,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;
|
||||
let zoom_factor = egui_ctx.input(|input| {
|
||||
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
|
||||
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
|
||||
@@ -528,22 +605,12 @@ impl EyeController {
|
||||
|
||||
match self.kind {
|
||||
Eye3DKind::Orbital => {
|
||||
@@ -531,2 +660,0 @@ impl EyeController {
|
||||
- let radius = self.pos.distance(self.look_target);
|
||||
-
|
||||
// Cap zoom-out against the scene bounding box. If we're already past the cap
|
||||
// (e.g. right after loading) use the current radius instead — no snap-back.
|
||||
@@ -534,0 +663 @@ impl EyeController {
|
||||
+ let radius = self.radius();
|
||||
let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
|
||||
@@ -536,11 +665,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
|
||||
@@ -112,22 +173,48 @@ index e59b311..3f33259 100644
|
||||
- }
|
||||
+ let pointer = response.ctx.pointer_latest_pos();
|
||||
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer);
|
||||
}
|
||||
Eye3DKind::FirstPerson => {
|
||||
// Move along the forward axis when zooming in first person mode.
|
||||
@@ -687,7 +754,7 @@ impl EyeController {
|
||||
self.handle_drag(response, drag_threshold);
|
||||
|
||||
if response.hovered() {
|
||||
@@ -669,0 +790 @@ impl EyeController {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -671,0 +793,5 @@ impl EyeController {
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ self.speed = self
|
||||
+ .speed
|
||||
+ .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64);
|
||||
+ }
|
||||
@@ -687 +813 @@ impl EyeController {
|
||||
- self.handle_drag(response, drag_threshold);
|
||||
+ self.handle_drag(eye_state, response, drag_threshold, pointer_space_position);
|
||||
@@ -690 +816 @@ impl EyeController {
|
||||
- self.handle_zoom(&response.ctx, scene_bounding_box);
|
||||
+ self.handle_zoom(response, scene_bounding_box);
|
||||
}
|
||||
|
||||
if response.has_focus() {
|
||||
@@ -1249,3 +1316,103 @@ impl EyeState {
|
||||
Ok(eye)
|
||||
}
|
||||
}
|
||||
@@ -728,0 +855,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
|
||||
+/// limit that makes both WASD and cursor-directed dolly effectively stop.
|
||||
+fn minimum_orbital_navigation_speed(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
+ const FALLBACK: f32 = 0.25;
|
||||
+ const SCENE_DIAGONAL_FACTOR: f32 = 0.01;
|
||||
+
|
||||
+ if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ let scene_diagonal = scene_bounding_box.size().length();
|
||||
+ if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK)
|
||||
+}
|
||||
+
|
||||
@@ -773,0 +918 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -816,0 +962 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -1189,0 +1336 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -1203,0 +1351 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -1251,0 +1400,126 @@ impl EyeState {
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
@@ -177,8 +264,8 @@ index e59b311..3f33259 100644
|
||||
+ .pointer_ray_direction(rect, pointer)
|
||||
+ .expect("test pointer must produce a ray");
|
||||
+ let forward = controller.fwd();
|
||||
+ let anchor = controller.pos
|
||||
+ + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+ let anchor =
|
||||
+ controller.pos + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer));
|
||||
+
|
||||
@@ -199,10 +286,7 @@ index e59b311..3f33259 100644
|
||||
+
|
||||
+ assert!((controller.radius() - radius).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(
|
||||
+ controller.look_target,
|
||||
+ old_target + Vec3::Y * 2.0_f32.ln(),
|
||||
+ );
|
||||
+ assert_vec3_close(controller.look_target, old_target + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius);
|
||||
+ }
|
||||
+
|
||||
@@ -227,4 +311,44 @@ index e59b311..3f33259 100644
|
||||
+
|
||||
+ assert!((controller.radius() - 25.0).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_rotation_keeps_selected_anchor_on_the_same_view_ray() {
|
||||
+ let anchor = vec3(3.0, 2.0, 1.5);
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 4.0), Vec3::ZERO);
|
||||
+ let original_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+
|
||||
+ controller.rotate_radians_around_anchor(egui::vec2(0.35, -0.2), anchor);
|
||||
+
|
||||
+ let rotated_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+ assert!(
|
||||
+ rotated_view_ray
|
||||
+ .normalize()
|
||||
+ .dot(original_view_ray.normalize())
|
||||
+ > 0.99999
|
||||
+ );
|
||||
+ assert!((rotated_view_ray.length() - original_view_ray.length()).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_navigation_speed_floor_tracks_scene_scale() {
|
||||
+ let building_scale = macaw::BoundingBox::from_min_max(Vec3::ZERO, vec3(300.0, 400.0, 0.0));
|
||||
+
|
||||
+ assert!((minimum_orbital_navigation_speed(&building_scale) - 5.0).abs() < 1.0e-6);
|
||||
+ assert_eq!(
|
||||
+ minimum_orbital_navigation_speed(&macaw::BoundingBox::nothing()),
|
||||
+ 0.25
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
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
|
||||
--- a/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
@@ -180,0 +181,4 @@ impl SpatialView3D {
|
||||
+ let pointer_space_position = state
|
||||
+ .previous_picking_result
|
||||
+ .as_ref()
|
||||
+ .and_then(crate::picking::PickingResult::space_position);
|
||||
@@ -186,0 +191 @@ impl SpatialView3D {
|
||||
+ pointer_space_position,
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user