fix(viewer): preserve operator camera across AI layers

This commit is contained in:
DCCONSTRUCTIONS
2026-07-23 10:51:18 +03:00
parent a0706fd5d8
commit 3a64f54dea
12 changed files with 425 additions and 109 deletions
@@ -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
@@ -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,