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

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: "1c25e8cecd7641e6f8044d00a6a1cf9d08a615b6f8737026c7d93623b3e06ee7", previousNodedcSha256: "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb",
nodedcSha256: "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb", nodedcSha256: "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da",
}, },
{ {
label: "wasm JavaScript glue", label: "wasm JavaScript glue",
@ -107,5 +107,5 @@ for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "inde
} }
console.log( console.log(
"Patched @rerun-io/web-viewer 0.34.1 native zoom-to-cursor, watchdog teardown, and singleton global keyup listener.", "Patched @rerun-io/web-viewer 0.34.1 pointer navigation, camera continuity, watchdog teardown, and singleton global keyup listener.",
); );

View File

@ -445,6 +445,7 @@ export async function fetchRecordedBlueprintRrd(
identity: RecordedRerunIdentity, identity: RecordedRerunIdentity,
{ {
origin, origin,
blueprintSessionId,
signal, signal,
activeView = "spatial", activeView = "spatial",
viewResetGeneration = 0, viewResetGeneration = 0,
@ -457,6 +458,7 @@ export async function fetchRecordedBlueprintRrd(
fetcher = globalThis.fetch, fetcher = globalThis.fetch,
}: { }: {
origin: string; origin: string;
blueprintSessionId: string;
signal?: AbortSignal; signal?: AbortSignal;
activeView?: RecordedRerunView; activeView?: RecordedRerunView;
viewResetGeneration?: 0 | 1; viewResetGeneration?: 0 | 1;
@ -488,7 +490,8 @@ export async function fetchRecordedBlueprintRrd(
perceptionLayers.cuboids3d, perceptionLayers.cuboids3d,
].some((value) => typeof value !== "boolean") || ].some((value) => typeof value !== "boolean") ||
identity.applicationId !== "nodedc_mission_core_recorded" || identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
!/^[a-f0-9]{32}$/.test(blueprintSessionId)
) { ) {
throw new Error("Unsafe recorded blueprint request"); throw new Error("Unsafe recorded blueprint request");
} }
@ -502,6 +505,7 @@ export async function fetchRecordedBlueprintRrd(
body: JSON.stringify({ body: JSON.stringify({
application_id: identity.applicationId, application_id: identity.applicationId,
recording_id: identity.recordingId, recording_id: identity.recordingId,
blueprint_session_id: blueprintSessionId,
accumulation_seconds: settings.accumulationSeconds, accumulation_seconds: settings.accumulationSeconds,
show_grid: settings.showGrid, show_grid: settings.showGrid,
show_points: settings.showPoints, show_points: settings.showPoints,
@ -635,6 +639,7 @@ export function RerunViewport({
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null); const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null); const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null); const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
const presentationGateRef = useRef(presentationGate); const presentationGateRef = useRef(presentationGate);
presentationGateRef.current = presentationGate; presentationGateRef.current = presentationGate;
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0); const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
@ -1223,6 +1228,7 @@ export function RerunViewport({
const abort = new AbortController(); const abort = new AbortController();
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, { void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
origin: window.location.origin, origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal, signal: abort.signal,
activeView: recordedView, activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration, viewResetGeneration: recordedViewResetGeneration,

View File

@ -336,8 +336,10 @@ function SpatialWorkspace({
(source) => source.capabilities.overlay && source.modality !== "point-cloud", (source) => source.capabilities.overlay && source.modality !== "point-cloud",
); );
const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable"; const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable";
const recordedPerceptionEnabled = showDetections2d || showSegmentation || showCuboids3d; // Keep one stable original-video + world composition for every admitted
const unifiedPerception = recordedPerceptionSupported && recordedPerceptionEnabled; // perception recording. Layer buttons then only change entity visibility;
// they never reparent the 3D view or invalidate the operator's camera.
const unifiedPerception = recordedPerceptionSupported;
const livePerceptionAvailable = !recordedSource && streamActive; const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource const detections2dActive = recordedSource
? showDetections2d ? showDetections2d

View File

@ -269,6 +269,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" }, { applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{ {
origin: "http://127.0.0.1:5174", origin: "http://127.0.0.1:5174",
blueprintSessionId: "a".repeat(32),
activeView: "perception3d", activeView: "perception3d",
viewResetGeneration: 1, viewResetGeneration: 1,
perceptionLayers: { perceptionLayers: {
@ -292,6 +293,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
assert.deepEqual(calls[0].body, { assert.deepEqual(calls[0].body, {
application_id: "nodedc_mission_core_recorded", application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001", recording_id: "recording-001",
blueprint_session_id: "a".repeat(32),
accumulation_seconds: 24, accumulation_seconds: 24,
show_grid: false, show_grid: false,
show_points: true, show_points: true,
@ -320,7 +322,11 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
customColor: "#35d7c1", customColor: "#35d7c1",
}, },
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" }, { applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{ origin: "http://127.0.0.1:5174", fetcher: async () => new Response(payload) }, {
origin: "http://127.0.0.1:5174",
blueprintSessionId: "a".repeat(32),
fetcher: async () => new Response(payload),
},
), ),
/Unsafe recorded blueprint request/, /Unsafe recorded blueprint request/,
); );

View File

@ -13,11 +13,11 @@ const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
const sha256 = (path) => const sha256 = (path) =>
createHash("sha256").update(readFileSync(path)).digest("hex"); createHash("sha256").update(readFileSync(path)).digest("hex");
test("NODE.DC Rerun runtime is the audited 0.34.1 zoom-to-cursor build", () => { 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 = "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb"; const expectedWasm = "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da";
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 cursor pivot, minimum-radius handoff, and geometry tests", () => { test("source patch carries pointer navigation, camera continuity, and geometry 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/);
@ -64,4 +64,10 @@ test("source patch carries cursor pivot, minimum-radius handoff, and geometry te
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/); assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/); assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/); assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
assert.match(patch, /fn rotate_radians_around_anchor/);
assert.match(patch, /orbit_drag_anchor/);
assert.match(patch, /minimum_orbital_navigation_speed/);
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
assert.match(patch, /previous_picking_result/);
}); });

View File

@ -1,37 +1,42 @@
# NODE.DC Rerun web viewer 0.34.1 # NODE.DC Rerun web viewer 0.34.1
This directory contains the audited Mission Core camera-controller override for 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; - 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 - eye position and look target scale around that anchor, so the point under the
cursor remains under the cursor; 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 - 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 a cursor-directed dolly scaled by the scene's navigation speed instead of
silently ignoring the wheel or moving by imperceptible millimeters; silently ignoring the wheel or moving by imperceptible millimeters;
- first-person movement, orbit rotation, panning, WASD and Rerun's zoom-out cap - orbital navigation has a scene-scale speed floor, so WASD and post-limit
are unchanged. dolly remain useful at the minimum radius;
- first-person movement, panning and Rerun's zoom-out cap are unchanged.
## Source identity ## Source identity
- Upstream: `rerun-io/rerun` - Upstream: `rerun-io/rerun`
- Tag: `0.34.1` - Tag: `0.34.1`
- Commit: `4efb18f17f6f0e41985cda99a2bdcd012febc8d5` - 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` - Patch: `NODEDC_ZOOM_TO_CURSOR.patch`
- Rust: `1.92.0` - Rust: `1.92.0`
- Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`) - Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`)
- Build image: `rust:1.92-bookworm` - Build image: `rust:1.92-bookworm`
- Build image digest: - Build image digest:
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2` `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 ## Reproduction
Apply the patch to the exact upstream commit, then run Rerun's own builder: Apply the patch to the exact upstream commit, then run Rerun's own builder:
```sh ```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 test -p re_view_spatial --lib eye::tests:: -- --nocapture
cargo run -p re_dev_tools -- build-web-viewer \ cargo run -p re_dev_tools -- build-web-viewer \
--release -g \ --release -g \
@ -52,12 +57,13 @@ generated `re_viewer.js` is transformed with
| Artifact | SHA-256 | | Artifact | SHA-256 |
| --- | --- | | --- | --- |
| `re_viewer_bg.nodedc.wasm` | `38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb` | | `re_viewer_bg.nodedc.wasm` | `e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da` |
| 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 `5 passed, 0 failed`, including the The focused Rust suite completed with `7 passed, 0 failed`, including the
orbit-to-dolly boundary case. A Node `initSync` 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. 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,11 +1,69 @@
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..3f33259 100644 index e59b311..7159aab 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
@@ -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. + /// 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
@ -18,14 +76,24 @@ index e59b311..3f33259 100644
+ let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y); + let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y);
+ let aspect_ratio = rect.width() / rect.height(); + let aspect_ratio = rect.width() / rect.height();
+ let focal_scale = (fov_y * 0.5).tan(); + let focal_scale = (fov_y * 0.5).tan();
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0) + let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0) * focal_scale * aspect_ratio;
+ * focal_scale
+ * aspect_ratio;
+ let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale; + let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale;
+ +
+ (self.rotation() * vec3(x, y, -1.0)).try_normalize() + (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. + /// 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 + /// The position and look target are scaled around the same anchor. This preserves the
@ -48,12 +116,13 @@ index e59b311..3f33259 100644
+ let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius); + let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
+ let scale = new_radius / radius; + let scale = new_radius / radius;
+ +
+ let ray_direction = pointer.and_then(|pointer| self.pointer_ray_direction(rect, pointer)); + let pointer_ray = pointer.and_then(|pointer| {
+ if let Some(ray_direction) = ray_direction { + Some((
+ let forward = self.fwd(); + self.pointer_ray_direction(rect, pointer)?,
+ let denominator = ray_direction.dot(forward); + self.pointer_focus_plane_anchor(rect, pointer)?,
+ if denominator > 1.0e-4 { + ))
+ let anchor = self.pos + ray_direction * (radius / denominator); + });
+ if let Some((ray_direction, anchor)) = pointer_ray {
+ self.pos = anchor + (self.pos - anchor) * scale; + self.pos = anchor + (self.pos - anchor) * scale;
+ self.look_target = anchor + (self.look_target - anchor) * scale; + self.look_target = anchor + (self.look_target - anchor) * scale;
+ +
@ -74,7 +143,6 @@ index e59b311..3f33259 100644
+ self.did_interact = true; + self.did_interact = true;
+ return; + return;
+ } + }
+ }
+ +
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom + // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
+ // behavior in that case instead of dropping the input. + // behavior in that case instead of dropping the input.
@ -82,23 +150,16 @@ index e59b311..3f33259 100644
+ self.did_interact = true; + 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, 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;
let zoom_factor = egui_ctx.input(|input| { @@ -531,2 +660,0 @@ impl EyeController {
// 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 => {
- let radius = self.pos.distance(self.look_target); - let radius = self.pos.distance(self.look_target);
- -
// Cap zoom-out against the scene bounding box. If we're already past the cap @@ -534,0 +663 @@ impl EyeController {
// (e.g. right after loading) use the current radius instead — no snap-back.
+ let radius = self.radius(); + 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); - 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
@ -112,22 +173,48 @@ index e59b311..3f33259 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 +790 @@ impl EyeController {
Eye3DKind::FirstPerson => { + pointer_space_position: Option<Vec3>,
// Move along the forward axis when zooming in first person mode. @@ -671,0 +793,5 @@ impl EyeController {
@@ -687,7 +754,7 @@ impl EyeController { + if self.kind == Eye3DKind::Orbital {
self.handle_drag(response, drag_threshold); + self.speed = self
+ .speed
if response.hovered() { + .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.ctx, scene_bounding_box);
+ self.handle_zoom(response, scene_bounding_box); + self.handle_zoom(response, scene_bounding_box);
} @@ -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.
if response.has_focus() { +///
@@ -1249,3 +1316,103 @@ impl EyeState { +/// The default orbital speed is the current orbit radius. Close to the near
Ok(eye) +/// 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)] +#[cfg(test)]
+mod tests { +mod tests {
@ -177,8 +264,8 @@ index e59b311..3f33259 100644
+ .pointer_ray_direction(rect, pointer) + .pointer_ray_direction(rect, pointer)
+ .expect("test pointer must produce a ray"); + .expect("test pointer must produce a ray");
+ let forward = controller.fwd(); + let forward = controller.fwd();
+ let anchor = controller.pos + let anchor =
+ + original_ray * (controller.radius() / original_ray.dot(forward)); + controller.pos + original_ray * (controller.radius() / original_ray.dot(forward));
+ +
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer)); + 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!((controller.radius() - radius).abs() < 1.0e-6);
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln()); + assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
+ assert_vec3_close( + assert_vec3_close(controller.look_target, old_target + Vec3::Y * 2.0_f32.ln());
+ controller.look_target,
+ old_target + Vec3::Y * 2.0_f32.ln(),
+ );
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius); + 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); + 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,

Binary file not shown.

View File

@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from threading import Lock
from typing import Any, Literal from typing import Any, Literal
from uuid import UUID from uuid import UUID
import rerun as rr import rerun as rr
import rerun_bindings as bindings
from rerun import blueprint as rrb from rerun import blueprint as rrb
from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color
@ -40,6 +43,97 @@ class RecordedBlueprintError(RuntimeError):
"""A viewer blueprint update could not be serialized safely.""" """A viewer blueprint update could not be serialized safely."""
class _RecordedBlueprintStream:
"""Keep one browser viewport on one mutable Rerun blueprint store.
Rerun persists the operator-controlled eye inside the active blueprint
store. Creating and activating a fresh store for every layer toggle drops
that eye and snaps the view back to its fallback camera. This stream keeps
the store identity stable until the operator explicitly requests a reset.
"""
def __init__(
self,
application_id: str,
*,
view_reset_generation: Literal[0, 1],
) -> None:
self.view_reset_generation = view_reset_generation
self._lock = Lock()
self._pending: list[bytes] = []
self._initial_payload: bytes | None = None
self._latest_payload = b""
native = bindings.new_blueprint(
application_id=application_id,
make_default=False,
make_thread_default=False,
default_enabled=True,
)
self._recording = rr.RecordingStream._from_native(native)
bindings.set_callback_sink_blueprint(
lambda chunk: self._pending.append(bytes(chunk)),
True,
False,
native,
)
self._recording.set_time("blueprint", sequence=0)
def render(self, blueprint: rrb.Blueprint) -> bytes:
with self._lock:
if self._initial_payload is not None:
self._pending.clear()
blueprint._log_to_stream(self._recording)
self._recording.flush(timeout_sec=5.0)
delta = b"".join(self._pending)
if not delta:
raise RecordedBlueprintError("stable blueprint stream produced no data")
if self._initial_payload is None:
self._initial_payload = delta
self._latest_payload = b""
else:
self._latest_payload = delta
return self._initial_payload + self._latest_payload
def close(self) -> None:
with suppress(Exception):
self._recording.disconnect()
_MAX_RECORDED_BLUEPRINT_STREAMS = 32
_recorded_blueprint_streams_lock = Lock()
_recorded_blueprint_streams: OrderedDict[
tuple[str, str, str], _RecordedBlueprintStream
] = OrderedDict()
def _stable_recorded_blueprint_stream(
*,
application_id: str,
recording_id: str,
blueprint_session_id: str,
view_reset_generation: Literal[0, 1],
) -> _RecordedBlueprintStream:
key = (application_id, recording_id, blueprint_session_id)
with _recorded_blueprint_streams_lock:
stream = _recorded_blueprint_streams.get(key)
if stream is not None and stream.view_reset_generation != view_reset_generation:
stream.close()
del _recorded_blueprint_streams[key]
stream = None
if stream is None:
stream = _RecordedBlueprintStream(
application_id,
view_reset_generation=view_reset_generation,
)
_recorded_blueprint_streams[key] = stream
else:
_recorded_blueprint_streams.move_to_end(key)
while len(_recorded_blueprint_streams) > _MAX_RECORDED_BLUEPRINT_STREAMS:
_, stale = _recorded_blueprint_streams.popitem(last=False)
stale.close()
return stream
def recorded_blueprint( def recorded_blueprint(
settings: RerunSceneSettings, settings: RerunSceneSettings,
*, *,
@ -254,6 +348,7 @@ def recorded_blueprint_rrd(
*, *,
application_id: str = APPLICATION_ID, application_id: str = APPLICATION_ID,
recording_id: str, recording_id: str,
blueprint_session_id: str | None = None,
active_view: RecordedView = "spatial", active_view: RecordedView = "spatial",
view_reset_generation: Literal[0, 1] = 0, view_reset_generation: Literal[0, 1] = 0,
unified_perception: bool = False, unified_perception: bool = False,
@ -263,15 +358,7 @@ def recorded_blueprint_rrd(
) -> bytes: ) -> bytes:
"""Serialize a bounded active blueprint update without recorded data.""" """Serialize a bounded active blueprint update without recorded data."""
recording = rr.RecordingStream( blueprint = recorded_blueprint(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
recorded_blueprint(
settings, settings,
include_initial_playback_state=False, include_initial_playback_state=False,
active_view=active_view, active_view=active_view,
@ -280,7 +367,30 @@ def recorded_blueprint_rrd(
show_detections_2d=show_detections_2d, show_detections_2d=show_detections_2d,
show_segmentation=show_segmentation, show_segmentation=show_segmentation,
show_cuboids_3d=show_cuboids_3d, show_cuboids_3d=show_cuboids_3d,
), )
payload: bytes | None
if blueprint_session_id is not None:
try:
payload = _stable_recorded_blueprint_stream(
application_id=application_id,
recording_id=recording_id,
blueprint_session_id=blueprint_session_id,
view_reset_generation=view_reset_generation,
).render(blueprint)
except RecordedBlueprintError:
raise
except Exception as exc:
raise RecordedBlueprintError("failed to serialize stable recorded blueprint") from exc
else:
recording = rr.RecordingStream(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
blueprint,
make_active=True, make_active=True,
make_default=False, make_default=False,
) )

View File

@ -98,6 +98,11 @@ class RecordedBlueprintRequest(StrictApiModel):
max_length=128, max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
) )
blueprint_session_id: str = Field(
min_length=32,
max_length=32,
pattern=r"^[a-f0-9]{32}$",
)
accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0) accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
show_points: StrictBool show_points: StrictBool
show_trajectory: StrictBool show_trajectory: StrictBool
@ -800,6 +805,7 @@ def build_session_router(
), ),
application_id=RECORDED_APPLICATION_ID, application_id=RECORDED_APPLICATION_ID,
recording_id=request.recording_id, recording_id=request.recording_id,
blueprint_session_id=request.blueprint_session_id,
active_view=request.active_view, active_view=request.active_view,
view_reset_generation=request.view_reset_generation, view_reset_generation=request.view_reset_generation,
unified_perception=request.unified_perception, unified_perception=request.unified_perception,

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import struct import struct
import subprocess import subprocess
import sys import sys
@ -35,7 +36,12 @@ from k1link.device_plugins.xgrids_k1.rrd_export import (
export_k1mqtt_to_rrd, export_k1mqtt_to_rrd,
recorded_blueprint_rrd, recorded_blueprint_rrd,
) )
from k1link.viewer.recorded import recorded_blueprint as viewer_recorded_blueprint from k1link.viewer.recorded import (
recorded_blueprint as viewer_recorded_blueprint,
)
from k1link.viewer.recorded import (
recorded_blueprint_rrd as viewer_recorded_blueprint_rrd,
)
from k1link.viewer.rerun_bridge import RerunSceneSettings from k1link.viewer.rerun_bridge import RerunSceneSettings
@ -410,6 +416,47 @@ def test_viewer_blueprint_reset_is_bounded_and_recreates_render_views() -> None:
assert perception_behavior.visible.as_arrow_array().to_pylist() == [False] assert perception_behavior.visible.as_arrow_array().to_pylist() == [False]
def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset() -> None:
session_id = "b" * 32
initial = viewer_recorded_blueprint_rrd(
RerunSceneSettings(accumulation_seconds=12.0),
recording_id="stable-camera",
blueprint_session_id=session_id,
unified_perception=True,
)
layers_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,
)
reset = viewer_recorded_blueprint_rrd(
RerunSceneSettings(accumulation_seconds=12.0),
recording_id="stable-camera",
blueprint_session_id=session_id,
view_reset_generation=1,
unified_perception=True,
show_detections_2d=True,
show_segmentation=True,
show_cuboids_3d=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))
reset_store_ids = set(re.findall(store_pattern, reset))
assert len(initial_store_ids) == 1
assert layer_store_ids == initial_store_ids
assert len(reset_store_ids) == 1
assert reset_store_ids.isdisjoint(initial_store_ids)
assert len(initial) < 350_000
assert len(layers_enabled) < 350_000
assert len(reset) < 350_000
def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() -> None: def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() -> None:
blueprint = viewer_recorded_blueprint( blueprint = viewer_recorded_blueprint(
RerunSceneSettings(accumulation_seconds=12.0), RerunSceneSettings(accumulation_seconds=12.0),

View File

@ -1077,6 +1077,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
request=RecordedBlueprintRequest( request=RecordedBlueprintRequest(
application_id="nodedc_mission_core_recorded", application_id="nodedc_mission_core_recorded",
recording_id="recording-001", recording_id="recording-001",
blueprint_session_id="a" * 32,
accumulation_seconds=18.5, accumulation_seconds=18.5,
show_points=False, show_points=False,
show_trajectory=True, show_trajectory=True,
@ -1098,11 +1099,12 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
assert response.headers["cache-control"] == "no-store" assert response.headers["cache-control"] == "no-store"
assert response.headers["x-content-type-options"] == "nosniff" assert response.headers["x-content-type-options"] == "nosniff"
assert response.body.startswith(b"RRF2") assert response.body.startswith(b"RRF2")
assert len(response.body) < 100_000 assert len(response.body) < 250_000
assert_no_local_paths(response.headers, repository) assert_no_local_paths(response.headers, repository)
assert len(observed_settings) == 1 assert len(observed_settings) == 1
assert observed_settings[0].show_points is False assert observed_settings[0].show_points is False
assert observed_settings[0].show_trajectory is True assert observed_settings[0].show_trajectory is True
assert observed_kwargs[0]["blueprint_session_id"] == "a" * 32
assert observed_kwargs[0]["active_view"] == "perception" assert observed_kwargs[0]["active_view"] == "perception"
assert observed_kwargs[0]["view_reset_generation"] == 1 assert observed_kwargs[0]["view_reset_generation"] == 1
assert observed_kwargs[0]["unified_perception"] is True assert observed_kwargs[0]["unified_perception"] is True
@ -1127,6 +1129,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
request=RecordedBlueprintRequest( request=RecordedBlueprintRequest(
application_id="nodedc_mission_core_recorded", application_id="nodedc_mission_core_recorded",
recording_id="recording-001", recording_id="recording-001",
blueprint_session_id="a" * 32,
accumulation_seconds=12, accumulation_seconds=12,
show_points=True, show_points=True,
show_trajectory=True, show_trajectory=True,