diff --git a/apps/control-station/scripts/patch-rerun-web-viewer.mjs b/apps/control-station/scripts/patch-rerun-web-viewer.mjs index 0c0cc77..0ca127b 100644 --- a/apps/control-station/scripts/patch-rerun-web-viewer.mjs +++ b/apps/control-station/scripts/patch-rerun-web-viewer.mjs @@ -23,8 +23,8 @@ const vendorRuntime = [ packagePath: resolve(packageRoot, "re_viewer_bg.wasm"), vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"), publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35", - previousNodedcSha256: "1c25e8cecd7641e6f8044d00a6a1cf9d08a615b6f8737026c7d93623b3e06ee7", - nodedcSha256: "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb", + previousNodedcSha256: "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb", + nodedcSha256: "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da", }, { label: "wasm JavaScript glue", @@ -107,5 +107,5 @@ for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "inde } 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.", ); diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 8bfc1ca..583a03d 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -445,6 +445,7 @@ export async function fetchRecordedBlueprintRrd( identity: RecordedRerunIdentity, { origin, + blueprintSessionId, signal, activeView = "spatial", viewResetGeneration = 0, @@ -457,6 +458,7 @@ export async function fetchRecordedBlueprintRrd( fetcher = globalThis.fetch, }: { origin: string; + blueprintSessionId: string; signal?: AbortSignal; activeView?: RecordedRerunView; viewResetGeneration?: 0 | 1; @@ -488,7 +490,8 @@ export async function fetchRecordedBlueprintRrd( perceptionLayers.cuboids3d, ].some((value) => typeof value !== "boolean") || 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"); } @@ -502,6 +505,7 @@ export async function fetchRecordedBlueprintRrd( body: JSON.stringify({ application_id: identity.applicationId, recording_id: identity.recordingId, + blueprint_session_id: blueprintSessionId, accumulation_seconds: settings.accumulationSeconds, show_grid: settings.showGrid, show_points: settings.showPoints, @@ -635,6 +639,7 @@ export function RerunViewport({ const perceptionChannelRef = useRef(null); const loadedPerceptionChannelRef = useRef(null); const recordedIdentityRef = useRef(null); + const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", "")); const presentationGateRef = useRef(presentationGate); presentationGateRef.current = presentationGate; const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0); @@ -1223,6 +1228,7 @@ export function RerunViewport({ const abort = new AbortController(); void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, { origin: window.location.origin, + blueprintSessionId: blueprintSessionIdRef.current, signal: abort.signal, activeView: recordedView, viewResetGeneration: recordedViewResetGeneration, diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 706cd9b..4645751 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -336,8 +336,10 @@ function SpatialWorkspace({ (source) => source.capabilities.overlay && source.modality !== "point-cloud", ); const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable"; - const recordedPerceptionEnabled = showDetections2d || showSegmentation || showCuboids3d; - const unifiedPerception = recordedPerceptionSupported && recordedPerceptionEnabled; + // Keep one stable original-video + world composition for every admitted + // 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 detections2dActive = recordedSource ? showDetections2d diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs index 7a4bc0d..b5939f8 100644 --- a/apps/control-station/test/observationSources.test.mjs +++ b/apps/control-station/test/observationSources.test.mjs @@ -269,6 +269,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting { applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" }, { origin: "http://127.0.0.1:5174", + blueprintSessionId: "a".repeat(32), activeView: "perception3d", viewResetGeneration: 1, perceptionLayers: { @@ -292,6 +293,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting assert.deepEqual(calls[0].body, { application_id: "nodedc_mission_core_recorded", recording_id: "recording-001", + blueprint_session_id: "a".repeat(32), accumulation_seconds: 24, show_grid: false, show_points: true, @@ -320,7 +322,11 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting customColor: "#35d7c1", }, { 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/, ); diff --git a/apps/control-station/test/rerunWebViewerVendor.test.mjs b/apps/control-station/test/rerunWebViewerVendor.test.mjs index 50014df..3554b6e 100644 --- a/apps/control-station/test/rerunWebViewerVendor.test.mjs +++ b/apps/control-station/test/rerunWebViewerVendor.test.mjs @@ -13,11 +13,11 @@ const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1"); const sha256 = (path) => 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")); assert.equal(manifest.version, "0.34.1"); - const expectedWasm = "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb"; + const expectedWasm = "e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da"; const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339"; assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm); @@ -55,7 +55,7 @@ test("custom Rerun WASM initializes and grows its externref table", () => { runtime.deinit(); }); -test("source patch carries 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"); 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, /remaining_zoom_factor\.ln\(\) \* self\.speed/); 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/); }); diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md b/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md index 1dc915e..ed5e301 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/BUILD_INFO.md @@ -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 diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch b/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch index 5c6b0a7..23fc05f 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch @@ -1,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, ++ +@@ -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, ++ ) { ++ 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 { ++ 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, +@@ -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, +@@ -816,0 +962 @@ impl EyeState { ++ pointer_space_position, +@@ -1189,0 +1336 @@ impl EyeState { ++ pointer_space_position: Option, +@@ -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, diff --git a/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm b/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm index 82593e2..13c6567 100644 --- a/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm +++ b/apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb -size 48595790 +oid sha256:e85dbad1569431620d4fabb18c1dbddd3a26d071eec1bb88a036fa755bc921da +size 48596892 diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py index d9361bb..1471b0e 100644 --- a/src/k1link/viewer/recorded.py +++ b/src/k1link/viewer/recorded.py @@ -2,11 +2,14 @@ from __future__ import annotations +from collections import OrderedDict from contextlib import suppress +from threading import Lock from typing import Any, Literal from uuid import UUID import rerun as rr +import rerun_bindings as bindings from rerun import blueprint as rrb 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.""" +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( settings: RerunSceneSettings, *, @@ -254,6 +348,7 @@ def recorded_blueprint_rrd( *, application_id: str = APPLICATION_ID, recording_id: str, + blueprint_session_id: str | None = None, active_view: RecordedView = "spatial", view_reset_generation: Literal[0, 1] = 0, unified_perception: bool = False, @@ -263,33 +358,48 @@ def recorded_blueprint_rrd( ) -> bytes: """Serialize a bounded active blueprint update without recorded data.""" - recording = rr.RecordingStream( - application_id, - recording_id=recording_id, - send_properties=False, + blueprint = recorded_blueprint( + settings, + include_initial_playback_state=False, + active_view=active_view, + view_reset_generation=view_reset_generation, + unified_perception=unified_perception, + show_detections_2d=show_detections_2d, + show_segmentation=show_segmentation, + show_cuboids_3d=show_cuboids_3d, ) - stream = rr.binary_stream(recording) - try: - recording.send_blueprint( - recorded_blueprint( - settings, - include_initial_playback_state=False, - active_view=active_view, + 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, - unified_perception=unified_perception, - show_detections_2d=show_detections_2d, - show_segmentation=show_segmentation, - show_cuboids_3d=show_cuboids_3d, - ), - make_active=True, - make_default=False, + ).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, ) - payload = stream.read(flush=True, flush_timeout_sec=5.0) - except Exception as exc: - raise RecordedBlueprintError("failed to serialize recorded blueprint") from exc - finally: - with suppress(Exception): - recording.disconnect() + stream = rr.binary_stream(recording) + try: + recording.send_blueprint( + blueprint, + make_active=True, + make_default=False, + ) + payload = stream.read(flush=True, flush_timeout_sec=5.0) + except Exception as exc: + raise RecordedBlueprintError("failed to serialize recorded blueprint") from exc + finally: + with suppress(Exception): + recording.disconnect() if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576: raise RecordedBlueprintError("serialized recorded blueprint is invalid") return payload diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index 77b6d63..c35bc55 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -98,6 +98,11 @@ class RecordedBlueprintRequest(StrictApiModel): max_length=128, 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) show_points: StrictBool show_trajectory: StrictBool @@ -800,6 +805,7 @@ def build_session_router( ), application_id=RECORDED_APPLICATION_ID, recording_id=request.recording_id, + blueprint_session_id=request.blueprint_session_id, active_view=request.active_view, view_reset_generation=request.view_reset_generation, unified_perception=request.unified_perception, diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py index 29d42ec..bad8607 100644 --- a/tests/test_rrd_export.py +++ b/tests/test_rrd_export.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import json +import re import struct import subprocess import sys @@ -35,7 +36,12 @@ from k1link.device_plugins.xgrids_k1.rrd_export import ( export_k1mqtt_to_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 @@ -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] +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: blueprint = viewer_recorded_blueprint( RerunSceneSettings(accumulation_seconds=12.0), diff --git a/tests/test_session_api.py b/tests/test_session_api.py index 53d977a..a7c902f 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -1077,6 +1077,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped( request=RecordedBlueprintRequest( application_id="nodedc_mission_core_recorded", recording_id="recording-001", + blueprint_session_id="a" * 32, accumulation_seconds=18.5, show_points=False, 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["x-content-type-options"] == "nosniff" 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 len(observed_settings) == 1 assert observed_settings[0].show_points is False 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]["view_reset_generation"] == 1 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( application_id="nodedc_mission_core_recorded", recording_id="recording-001", + blueprint_session_id="a" * 32, accumulation_seconds=12, show_points=True, show_trajectory=True,