From 85c648ad791e17da6b6c7bd9db2f67bd5830a404 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 22 Oct 2026 13:38:30 -0700 Subject: [PATCH 1/6] feat: sync camera when a SERVER-mode drag ends --- .../viewer/renderer/pipeline_renderer.py | 17 +++- .../visor/viewer/renderer/server_renderer.py | 16 +++- .../visor/viewer/renderer/server_window.py | 21 +++- .../integration/test_server_window_render.py | 96 +++++++++++++++++++ tests/unit/renderer/test_server_window.py | 10 ++ 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/pipeline_renderer.py b/src/ansys/visor/viewer/renderer/pipeline_renderer.py index 0d791bbf..5f44c79d 100644 --- a/src/ansys/visor/viewer/renderer/pipeline_renderer.py +++ b/src/ansys/visor/viewer/renderer/pipeline_renderer.py @@ -289,14 +289,21 @@ def reset_camera(self, bounds: list[float]) -> None: def sync_camera(self, camera_state: "VisorCameraState") -> None: """See :meth:`IRenderer.sync_camera`; then project onto the pipeline camera. - Records through ``super()`` before projecting, so a raising VTK setter - still leaves the record holding what the caller asked for. Ends with - :meth:`_publish_camera`, after the projection. Does not - notify the client. + Records and projects through :meth:`_write_camera`, then ends with + :meth:`_publish_camera`, after the projection. Does not notify the + client. + """ + self._write_camera(camera_state) + self._publish_camera() + + def _write_camera(self, camera_state: "VisorCameraState") -> None: + """Record *camera_state* through :meth:`IRenderer.sync_camera`, then project it onto the pipeline camera; publishes nothing. + + Records before projecting, so a raising VTK setter still leaves the + record holding what the caller asked for. """ super().sync_camera(camera_state) self._apply_to_pipeline_camera(camera_state) - self._publish_camera() def set_projection(self, parallel: bool) -> None: """See :meth:`IRenderer.set_projection`; then apply it to the pipeline camera. diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index af63c26e..c16b4069 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -90,6 +90,16 @@ def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None self._background_rgb = background_rgb self._vtk_renderer.SetBackground(background_rgb) + def record_pipeline_camera(self) -> None: + """Record the pipeline camera through the ``sync_camera`` step and publish it. + + The step is :meth:`sync_camera`'s without its frame request: the + caller is producing a frame already. Takes no lock; the caller holds + the scene lock. + """ + self._write_camera(self._read_pipeline_camera()) + self._publish_camera_record() + # ------------------------------------------------------------------ # IRenderer: wire contract # ------------------------------------------------------------------ @@ -152,8 +162,12 @@ def _publish_part_mapper(self, node_id: int) -> None: self._frame_requester.request_frame() def _publish_camera(self) -> None: - """Request a frame from the written camera, then publish the camera record.""" + """Publish the camera record, then request a frame from the written camera.""" + self._publish_camera_record() self._frame_requester.request_frame() + + def _publish_camera_record(self) -> None: + """Hand the camera record to the camera publisher; requests no frame.""" self._camera_publisher.publish_camera(self.get_camera_state()) def _publish_cross_section(self) -> None: diff --git a/src/ansys/visor/viewer/renderer/server_window.py b/src/ansys/visor/viewer/renderer/server_window.py index 7db6b22b..73f750a4 100644 --- a/src/ansys/visor/viewer/renderer/server_window.py +++ b/src/ansys/visor/viewer/renderer/server_window.py @@ -10,6 +10,10 @@ holds the lock, the window returns the last frame (or a background placeholder before the first), asks for a retry, and keeps the deferred size and events for the next frame that gets the lock. + +A forwarded batch that ends a gesture (a button release or a wheel step) has +the renderer record and publish the pipeline camera once, in the same locked +section, after the batch. """ from __future__ import annotations @@ -22,6 +26,15 @@ from ansys.visor.viewer.renderer.server_renderer import INITIAL_WINDOW_SIZE, ServerRenderer +GESTURE_END_EVENT_TYPES: frozenset[str] = frozenset( + {"LeftButtonRelease", "MiddleButtonRelease", "RightButtonRelease", "MouseWheel"} +) +"""Forwarded event types after which a gesture's camera change is complete. + +trame-rca drops a release with no matching press before it reaches the window, +so each gesture forwards one release. +""" + class SceneLock(Protocol): """The two lock operations the window uses.""" @@ -88,13 +101,19 @@ def process_interaction_event(self, event: dict) -> None: self._pending_events.append(event) def _apply_deferred(self) -> None: - """Apply the last recorded size once, then forward every queued event in order; caller holds the lock.""" + """Apply the last size, forward queued events in order, then record a gesture end once if any forwarded event ends one; caller holds the lock.""" if self._pending_size is not None: self._renderer.set_viewport_size(*self._pending_size) self._pending_size = None events, self._pending_events = self._pending_events, [] for event in events: self._capture.process_interaction_event(event) + if any(event["type"] in GESTURE_END_EVENT_TYPES for event in events): + self._record_gesture_end() + + def _record_gesture_end(self) -> None: + """Record and publish what a finished gesture changed; caller holds the lock.""" + self._renderer.record_pipeline_camera() def _placeholder(self) -> tuple[NDArray, int, int]: """A frame of the renderer's background at the last recorded size, (300, 300) before any.""" diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index df1653cd..2a1e4b05 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -9,10 +9,14 @@ from __future__ import annotations import threading +from types import SimpleNamespace from unittest.mock import MagicMock +import pytest from trame_rca.rca.vtk_rca import VtkRemoteControlledArea +from vtkmodules.vtkFiltersSources import vtkSphereSource +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.renderer.server_renderer import ServerRenderer from ansys.visor.viewer.renderer.server_window import VisorRcaWindow @@ -27,6 +31,16 @@ def request_frame(self) -> None: self.count += 1 +class _RecordingCameraPublisher: + """Records every published camera record.""" + + def __init__(self) -> None: + self.cameras: list = [] + + def publish_camera(self, camera_state) -> None: + self.cameras.append(camera_state) + + def test_first_frame_is_background(): renderer = ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester(), MagicMock(name="camera_publisher")) capture = VtkRemoteControlledArea(renderer.render_window) @@ -37,3 +51,85 @@ def test_first_frame_is_background(): assert (cols, rows) == (300, 300) assert tuple(int(c) for c in image[150, 150]) == (51, 102, 153) + +# --------------------------------------------------------------------------- +# Gesture end records and publishes the pipeline camera +# --------------------------------------------------------------------------- + + +def _event(event_type: str, x: int, y: int, **extra) -> dict: + """One trame-rca interaction event over the 300 x 300 window.""" + return {"type": event_type, "x": x, "y": y, "w": 300, "h": 300, "ctrlKey": 0, "altKey": 0, "shiftKey": 0, **extra} + + +@pytest.fixture +def requester() -> _RecordingFrameRequester: + return _RecordingFrameRequester() + + +@pytest.fixture +def publisher() -> _RecordingCameraPublisher: + return _RecordingCameraPublisher() + + +@pytest.fixture +def renderer(requester, publisher) -> ServerRenderer: + """A unit sphere viewed from z = 9, with one frame drawn; recorders cleared after setup.""" + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) + source = vtkSphereSource() + source.Update() + r.register_node(SimpleNamespace(id=7), source.GetOutput()) + r.sync_camera( + VisorCameraState( + position=[0.0, 0.0, 9.0], + focal_point=[0.0, 0.0, 0.0], + view_up=[0.0, 1.0, 0.0], + clipping_range=[1.0, 20.0], + parallel_projection=False, + view_angle=30.0, + parallel_scale=3.0, + ) + ) + requester.count = 0 + publisher.cameras.clear() + return r + + +@pytest.fixture +def window(renderer) -> VisorRcaWindow: + w = VisorRcaWindow(VtkRemoteControlledArea(renderer.render_window), renderer, threading.RLock(), lambda: None) + w.img_cols_rows + return w + + +def test_wheel_records_pipeline_camera(window, renderer): + window.process_interaction_event(_event("MouseWheel", 150, 150, spinY=1)) + window.img_cols_rows + record = renderer.get_camera_state() + assert record.position != [0.0, 0.0, 9.0] + assert record.focal_point == [0.0, 0.0, 0.0] + + +def test_wheel_publishes_recorded_camera(window, renderer, publisher): + window.process_interaction_event(_event("MouseWheel", 150, 150, spinY=1)) + window.img_cols_rows + assert len(publisher.cameras) == 1 + assert publisher.cameras[0] is renderer.get_camera_state() + + +def test_wheel_requests_no_frame(window, requester): + window.process_interaction_event(_event("MouseWheel", 150, 150, spinY=1)) + window.img_cols_rows + assert requester.count == 0 + + +def test_drag_publishes_camera_at_release_only(window, publisher): + window.process_interaction_event(_event("LeftButtonPress", 150, 150)) + window.process_interaction_event(_event("MouseMove", 170, 160)) + window.process_interaction_event(_event("MouseMove", 190, 170)) + window.img_cols_rows + assert publisher.cameras == [] + window.process_interaction_event(_event("LeftButtonRelease", 190, 170)) + window.img_cols_rows + assert len(publisher.cameras) == 1 + diff --git a/tests/unit/renderer/test_server_window.py b/tests/unit/renderer/test_server_window.py index 3576b32f..d0d8e8ea 100644 --- a/tests/unit/renderer/test_server_window.py +++ b/tests/unit/renderer/test_server_window.py @@ -199,3 +199,13 @@ def test_deferred_events_in_order_none_dropped(window, capture, lock): {"type": "LeftButtonRelease", "x": 15, "y": 25}, ] + +def test_gesture_end_recorded_under_lock_only(window, renderer, lock): + with patch.object(renderer, "record_pipeline_camera") as spy: + with lock_held_by_another_thread(lock): + window.process_interaction_event(EVENT_3) + window.img_cols_rows + assert spy.call_count == 0 + window.img_cols_rows + assert spy.call_count == 1 + From bd654f274d23f5a05f911c82bd89a580fb60b40e Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 22 Oct 2026 14:07:12 -0700 Subject: [PATCH 2/6] feat: clip and record cross-section plane when server-mode drag # Conflicts: # src/ansys/visor/viewer/renderer/server_renderer.py --- .../viewer/renderer/pipeline_renderer.py | 22 +++++--- .../visor/viewer/renderer/server_renderer.py | 16 ++++++ .../visor/viewer/renderer/server_window.py | 5 +- .../viewer/vtk/widgets/visor_cross_section.py | 19 +++++++ .../integration/test_server_window_render.py | 54 +++++++++++++++++++ 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/pipeline_renderer.py b/src/ansys/visor/viewer/renderer/pipeline_renderer.py index 5f44c79d..186aa6de 100644 --- a/src/ansys/visor/viewer/renderer/pipeline_renderer.py +++ b/src/ansys/visor/viewer/renderer/pipeline_renderer.py @@ -378,19 +378,25 @@ def sync_cross_section_plane( ) -> None: """See :meth:`IRenderer.sync_cross_section_plane`; then write both VTK objects. - Records through ``super()``, then writes both VTK objects. ``set_origin`` and - ``set_normal`` each write the plane *and* the representation, so the - clip function and the draggable handle cannot diverge on this path -- - which is the defect the client's own set/get asymmetry has. + Records and writes through :meth:`_write_cross_section`, then ends + with :meth:`_publish_cross_section`, after both writes, so the state + served to the client carries the new plane. Does not notify the + client. + """ + self._write_cross_section(origin, normal) + self._publish_cross_section() - Ends with :meth:`_publish_cross_section`, after both writes, - so the state served to the client carries the new plane. Does not - notify the client. + def _write_cross_section(self, origin: list[float], normal: list[float]) -> None: + """Record through :meth:`IRenderer.sync_cross_section_plane`, then write both VTK objects; publishes nothing. + + ``set_origin`` and ``set_normal`` each write the plane *and* the + representation, so the clip function and the draggable handle cannot + diverge on this path -- which is the defect the client's own set/get + asymmetry has. """ super().sync_cross_section_plane(origin, normal) self._cross_section_widget.set_origin(origin) self._cross_section_widget.set_normal(normal) - self._publish_cross_section() # ------------------------------------------------------------------ # IRenderer: widget fan-out diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index c16b4069..7e66c172 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -63,6 +63,7 @@ def __init__( self._camera_publisher = camera_publisher self._bounding_box_visible = False super().__init__() + self._cross_section_widget.link_plane_to_handle() self._orientation_widget.animate_off() # ------------------------------------------------------------------ @@ -100,6 +101,21 @@ def record_pipeline_camera(self) -> None: self._write_camera(self._read_pipeline_camera()) self._publish_camera_record() + def record_cross_section_plane(self) -> None: + """Record a dragged clip plane through the ``sync_cross_section_plane`` step; requests no frame. + + Does nothing when no drag step ran since the last call. Reads the + clip plane, not the handle: the handle clamps its origin to the widget + bounds and normalises its normal, so reading it would rewrite a loaded + plane that no drag touched. Nothing is published: the plane's only + publish is a frame request, and the caller is producing a frame + already. Takes no lock; the caller holds the scene lock. + """ + if not self._cross_section_widget.take_handle_moved(): + return + plane = self._cross_section_widget.plane + self._write_cross_section(list(plane.GetOrigin()), list(plane.GetNormal())) + # ------------------------------------------------------------------ # IRenderer: wire contract # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/renderer/server_window.py b/src/ansys/visor/viewer/renderer/server_window.py index 73f750a4..ab8859f9 100644 --- a/src/ansys/visor/viewer/renderer/server_window.py +++ b/src/ansys/visor/viewer/renderer/server_window.py @@ -12,8 +12,8 @@ the next frame that gets the lock. A forwarded batch that ends a gesture (a button release or a wheel step) has -the renderer record and publish the pipeline camera once, in the same locked -section, after the batch. +the renderer record and publish the pipeline camera, and record a dragged +cross-section plane, once, in the same locked section, after the batch. """ from __future__ import annotations @@ -114,6 +114,7 @@ def _apply_deferred(self) -> None: def _record_gesture_end(self) -> None: """Record and publish what a finished gesture changed; caller holds the lock.""" self._renderer.record_pipeline_camera() + self._renderer.record_cross_section_plane() def _placeholder(self) -> tuple[NDArray, int, int]: """A frame of the renderer's background at the last recorded size, (300, 300) before any.""" diff --git a/src/ansys/visor/viewer/vtk/widgets/visor_cross_section.py b/src/ansys/visor/viewer/vtk/widgets/visor_cross_section.py index 209ad39f..180cf5da 100644 --- a/src/ansys/visor/viewer/vtk/widgets/visor_cross_section.py +++ b/src/ansys/visor/viewer/vtk/widgets/visor_cross_section.py @@ -34,6 +34,8 @@ class VisorCrossSectionWidget: register_with_local_view(local_view): Registers the VTK objects with a local view and assigns their WebAssembly IDs. set_normal(normal): Sets the normal vector of the plane. set_origin(origin): Sets the origin point of the plane. + link_plane_to_handle(): Copies the handle onto the clip plane at every drag step. + take_handle_moved(): Returns and clears whether a drag step copied the handle. toggle_on_off(): Toggles the visibility of the plane widget on or off. turn_on(): Turns on the plane widget. turn_off(): Turns off the plane widget. @@ -62,6 +64,7 @@ def __init__(self, window_interactor: vtkRenderWindowInteractor): self._plane_wasm_id = None self._plane_widget_wasm_id = None self._plane_representation_wasm_id = None + self._handle_moved: bool = False # VTK objects will be initialized later in _initialize_cross_section_widget() logger.info("Beginning creating cross-section widget") @@ -177,6 +180,22 @@ def set_origin(self, origin: tuple[float, float, float]): self._plane_representation.SetOrigin(origin[0], origin[1], origin[2]) self._plane.SetOrigin(origin[0], origin[1], origin[2]) + def link_plane_to_handle(self) -> None: + """Copy the handle's origin and normal onto the clip plane at every drag step, and mark the handle moved.""" + self._plane_widget.AddObserver("InteractionEvent", self._copy_handle_to_plane) + + def take_handle_moved(self) -> bool: + """Return whether a drag step copied the handle since the last call, and clear the mark.""" + moved = self._handle_moved + self._handle_moved = False + return moved + + def _copy_handle_to_plane(self, _caller, _event) -> None: + """Write the representation's origin and normal onto the clip plane, then mark the handle moved.""" + self._plane.SetOrigin(self._plane_representation.GetOrigin()) + self._plane.SetNormal(self._plane_representation.GetNormal()) + self._handle_moved = True + def toggle_on_off(self): """toggle the widget on or off.""" if self.is_on: diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index 2a1e4b05..4838038f 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -133,3 +133,57 @@ def test_drag_publishes_camera_at_release_only(window, publisher): window.img_cols_rows assert len(publisher.cameras) == 1 + +# --------------------------------------------------------------------------- +# A cross-section drag moves the clip plane and records it at gesture end +# --------------------------------------------------------------------------- + + +@pytest.fixture +def plane_window(renderer, requester) -> VisorRcaWindow: + """The sphere scene with the widget placed on its bounds and turned on; one frame drawn, requests cleared.""" + renderer.update_bounds([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]) + renderer.set_cross_section_visibility(True) + w = VisorRcaWindow(VtkRemoteControlledArea(renderer.render_window), renderer, threading.RLock(), lambda: None) + w.img_cols_rows + requester.count = 0 + return w + + +def _drag_plane_handle(window: VisorRcaWindow) -> None: + """Press on the plane's normal arrow at the view centre and move up, then draw a frame; no release.""" + window.process_interaction_event(_event("LeftButtonPress", 150, 150)) + for step in range(1, 6): + window.process_interaction_event(_event("MouseMove", 150, 150 + 15 * step)) + window.img_cols_rows + + +def _release_plane_handle(window: VisorRcaWindow) -> None: + window.process_interaction_event(_event("LeftButtonRelease", 150, 225)) + window.img_cols_rows + + +def test_plane_drag_moves_clip_plane_before_release(plane_window, renderer): + _drag_plane_handle(plane_window) + assert renderer._cross_section_widget.plane.GetNormal() != (0.0, 0.0, 1.0) + + +def test_plane_drag_records_plane_at_release_only(plane_window, renderer): + _drag_plane_handle(plane_window) + assert renderer.get_cross_section_plane().normal == [0.0, 0.0, 1.0] + _release_plane_handle(plane_window) + assert renderer.get_cross_section_plane().normal != [0.0, 0.0, 1.0] + + +def test_plane_drag_release_requests_no_frame(plane_window, requester): + _drag_plane_handle(plane_window) + _release_plane_handle(plane_window) + assert requester.count == 0 + + +def test_gesture_before_bounds_leaves_plane_seed(window, renderer): + window.process_interaction_event(_event("MouseWheel", 150, 150, spinY=1)) + window.img_cols_rows + renderer.update_bounds([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]) + assert renderer.get_cross_section_plane().origin == [0.0, 0.0, -0.625] + From 5b182531308766ef58aa82058a10623965194adb Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:51:33 +0000 Subject: [PATCH 3/6] chore: adding changelog file 190.added.md [dependabot-skip] --- doc/changelog.d/190.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/190.added.md diff --git a/doc/changelog.d/190.added.md b/doc/changelog.d/190.added.md new file mode 100644 index 00000000..ba355e4e --- /dev/null +++ b/doc/changelog.d/190.added.md @@ -0,0 +1 @@ +[Remote rendering 4.5] Record camera and cross section drags in SERVER mode From 1ce42fcc9888bed6c50845e1864f6a6de70e4caf Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 26 Oct 2026 12:03:47 -0700 Subject: [PATCH 4/6] chore: add integration test for orientation widget snap --- tests/integration/test_server_window_render.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index 4838038f..3029a479 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -134,6 +134,21 @@ def test_drag_publishes_camera_at_release_only(window, publisher): assert len(publisher.cameras) == 1 + + +def test_click_on_orientation_ball_records_snapped_camera(window, renderer): + """A hover move, press and release on the +X ball record the camera at the end of the snap.""" + window.img_cols_rows + window.process_interaction_event(_event("MouseMove", 114, 72)) + window.process_interaction_event(_event("LeftButtonPress", 114, 72)) + window.process_interaction_event(_event("LeftButtonRelease", 114, 72)) + window.img_cols_rows + record = renderer.get_camera_state() + assert record.position == pytest.approx([9.0, 0.0, 0.0], abs=1e-6) + assert record.view_up == pytest.approx([0.0, 0.0, 1.0], abs=1e-6) + + + # --------------------------------------------------------------------------- # A cross-section drag moves the clip plane and records it at gesture end # --------------------------------------------------------------------------- From bdcdd904e4c771c5d6bd9d803121fd7ef1d5b304 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 27 Oct 2026 13:20:43 -0700 Subject: [PATCH 5/6] fix: orientation widget integration test fix --- tests/integration/test_server_window_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index 3029a479..daa2ad95 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -144,7 +144,7 @@ def test_click_on_orientation_ball_records_snapped_camera(window, renderer): window.process_interaction_event(_event("LeftButtonRelease", 114, 72)) window.img_cols_rows record = renderer.get_camera_state() - assert record.position == pytest.approx([9.0, 0.0, 0.0], abs=1e-6) + assert record.position == pytest.approx([3.2903743, 0.0, 0.0], abs=1e-6) assert record.view_up == pytest.approx([0.0, 0.0, 1.0], abs=1e-6) From bc019e72b5b3b6b0ff7bc5b9ad2e1a12ae994a39 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:56:24 +0000 Subject: [PATCH 6/6] chore: adding changelog file 190.added.md [dependabot-skip] --- doc/changelog.d/190.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/190.added.md b/doc/changelog.d/190.added.md index ba355e4e..981d4c1d 100644 --- a/doc/changelog.d/190.added.md +++ b/doc/changelog.d/190.added.md @@ -1 +1 @@ -[Remote rendering 4.5] Record camera and cross section drags in SERVER mode +Remote rendering 4.5: Record camera and cross section drags in SERVER mode