Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/190.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.5: Record camera and cross section drags in SERVER mode
39 changes: 26 additions & 13 deletions src/ansys/visor/viewer/renderer/pipeline_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -371,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
Expand Down
32 changes: 31 additions & 1 deletion src/ansys/visor/viewer/renderer/server_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

# ------------------------------------------------------------------
Expand Down Expand Up @@ -90,6 +91,31 @@ 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()

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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -152,8 +178,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:
Expand Down
22 changes: 21 additions & 1 deletion src/ansys/visor/viewer/renderer/server_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, and record a dragged
cross-section plane, once, in the same locked section, after the batch.
"""

from __future__ import annotations
Expand All @@ -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."""
Expand Down Expand Up @@ -88,13 +101,20 @@ 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()
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."""
Expand Down
19 changes: 19 additions & 0 deletions src/ansys/visor/viewer/vtk/widgets/visor_cross_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
165 changes: 165 additions & 0 deletions tests/integration/test_server_window_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -37,3 +51,154 @@ 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




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([3.2903743, 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
# ---------------------------------------------------------------------------


@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]

Loading
Loading