fix(viewer): preserve operator camera across AI layers
This commit is contained in:
parent
a0706fd5d8
commit
3a64f54dea
|
|
@ -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.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,12 +116,13 @@ 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);
|
||||
+ 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;
|
||||
+
|
||||
|
|
@ -74,7 +143,6 @@ index e59b311..3f33259 100644
|
|||
+ self.did_interact = true;
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
|
||||
+ // behavior in that case instead of dropping the input.
|
||||
|
|
@ -82,23 +150,16 @@ index e59b311..3f33259 100644
|
|||
+ self.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
/// Handle zoom/scroll input.
|
||||
@@ -516 +645,2 @@ impl EyeController {
|
||||
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ let egui_ctx = &response.ctx;
|
||||
let zoom_factor = egui_ctx.input(|input| {
|
||||
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
|
||||
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
|
||||
@@ -528,22 +605,12 @@ impl EyeController {
|
||||
|
||||
match self.kind {
|
||||
Eye3DKind::Orbital => {
|
||||
@@ -531,2 +660,0 @@ impl EyeController {
|
||||
- let radius = self.pos.distance(self.look_target);
|
||||
-
|
||||
// Cap zoom-out against the scene bounding box. If we're already past the cap
|
||||
// (e.g. right after loading) use the current radius instead — no snap-back.
|
||||
@@ -534,0 +663 @@ impl EyeController {
|
||||
+ let radius = self.radius();
|
||||
let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
|
||||
@@ -536,11 +665,2 @@ impl EyeController {
|
||||
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
-
|
||||
- // The user may be scrolling to move the camera closer, but are not realizing
|
||||
|
|
@ -112,22 +173,48 @@ index e59b311..3f33259 100644
|
|||
- }
|
||||
+ let pointer = response.ctx.pointer_latest_pos();
|
||||
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer);
|
||||
}
|
||||
Eye3DKind::FirstPerson => {
|
||||
// Move along the forward axis when zooming in first person mode.
|
||||
@@ -687,7 +754,7 @@ impl EyeController {
|
||||
self.handle_drag(response, drag_threshold);
|
||||
|
||||
if response.hovered() {
|
||||
@@ -669,0 +790 @@ impl EyeController {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -671,0 +793,5 @@ impl EyeController {
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ self.speed = self
|
||||
+ .speed
|
||||
+ .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64);
|
||||
+ }
|
||||
@@ -687 +813 @@ impl EyeController {
|
||||
- self.handle_drag(response, drag_threshold);
|
||||
+ self.handle_drag(eye_state, response, drag_threshold, pointer_space_position);
|
||||
@@ -690 +816 @@ impl EyeController {
|
||||
- self.handle_zoom(&response.ctx, scene_bounding_box);
|
||||
+ self.handle_zoom(response, scene_bounding_box);
|
||||
}
|
||||
|
||||
if response.has_focus() {
|
||||
@@ -1249,3 +1316,103 @@ impl EyeState {
|
||||
Ok(eye)
|
||||
}
|
||||
}
|
||||
@@ -728,0 +855,18 @@ fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
+/// Lower bound for free-flight and post-limit zoom speed in orbital mode.
|
||||
+///
|
||||
+/// The default orbital speed is the current orbit radius. Close to the near
|
||||
+/// limit that makes both WASD and cursor-directed dolly effectively stop.
|
||||
+fn minimum_orbital_navigation_speed(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
+ const FALLBACK: f32 = 0.25;
|
||||
+ const SCENE_DIAGONAL_FACTOR: f32 = 0.01;
|
||||
+
|
||||
+ if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ let scene_diagonal = scene_bounding_box.size().length();
|
||||
+ if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK)
|
||||
+}
|
||||
+
|
||||
@@ -773,0 +918 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -816,0 +962 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -1189,0 +1336 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -1203,0 +1351 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -1251,0 +1400,126 @@ impl EyeState {
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
|
|
@ -177,8 +264,8 @@ index e59b311..3f33259 100644
|
|||
+ .pointer_ray_direction(rect, pointer)
|
||||
+ .expect("test pointer must produce a ray");
|
||||
+ let forward = controller.fwd();
|
||||
+ let anchor = controller.pos
|
||||
+ + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+ let anchor =
|
||||
+ controller.pos + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer));
|
||||
+
|
||||
|
|
@ -199,10 +286,7 @@ index e59b311..3f33259 100644
|
|||
+
|
||||
+ assert!((controller.radius() - radius).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(
|
||||
+ controller.look_target,
|
||||
+ old_target + Vec3::Y * 2.0_f32.ln(),
|
||||
+ );
|
||||
+ assert_vec3_close(controller.look_target, old_target + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius);
|
||||
+ }
|
||||
+
|
||||
|
|
@ -227,4 +311,44 @@ index e59b311..3f33259 100644
|
|||
+
|
||||
+ assert!((controller.radius() - 25.0).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_rotation_keeps_selected_anchor_on_the_same_view_ray() {
|
||||
+ let anchor = vec3(3.0, 2.0, 1.5);
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 4.0), Vec3::ZERO);
|
||||
+ let original_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+
|
||||
+ controller.rotate_radians_around_anchor(egui::vec2(0.35, -0.2), anchor);
|
||||
+
|
||||
+ let rotated_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+ assert!(
|
||||
+ rotated_view_ray
|
||||
+ .normalize()
|
||||
+ .dot(original_view_ray.normalize())
|
||||
+ > 0.99999
|
||||
+ );
|
||||
+ assert!((rotated_view_ray.length() - original_view_ray.length()).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_navigation_speed_floor_tracks_scene_scale() {
|
||||
+ let building_scale = macaw::BoundingBox::from_min_max(Vec3::ZERO, vec3(300.0, 400.0, 0.0));
|
||||
+
|
||||
+ assert!((minimum_orbital_navigation_speed(&building_scale) - 5.0).abs() < 1.0e-6);
|
||||
+ assert_eq!(
|
||||
+ minimum_orbital_navigation_speed(&macaw::BoundingBox::nothing()),
|
||||
+ 0.25
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
diff --git a/crates/viewer/re_view_spatial/src/ui_3d.rs b/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
index 56257a8..07498ab 100644
|
||||
--- a/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
@@ -180,0 +181,4 @@ impl SpatialView3D {
|
||||
+ let pointer_space_position = state
|
||||
+ .previous_picking_result
|
||||
+ .as_ref()
|
||||
+ .and_then(crate::picking::PickingResult::space_position);
|
||||
@@ -186,0 +191 @@ impl SpatialView3D {
|
||||
+ pointer_space_position,
|
||||
|
|
|
|||
BIN
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm (Stored with Git LFS)
vendored
BIN
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm (Stored with Git LFS)
vendored
Binary file not shown.
|
|
@ -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,15 +358,7 @@ 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,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.send_blueprint(
|
||||
recorded_blueprint(
|
||||
blueprint = recorded_blueprint(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
active_view=active_view,
|
||||
|
|
@ -280,7 +367,30 @@ def recorded_blueprint_rrd(
|
|||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
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_default=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue