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/196.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.5b: Send the cross-section plane to the server-mode client
11 changes: 10 additions & 1 deletion src/ansys/visor/dash/src/lib/components/Visordash.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ const Visordash = (props) => {
(async () => {
const snapFn = window.__visorState?.getAppStateAsync;
if (typeof snapFn === 'function' && typeof setPropsRef.current === 'function') {
const appState = await snapFn(/*pass 'true' to key by name*/ true);
let appState;
try {
appState = await snapFn(/*pass 'true' to key by name*/ true);
} catch (err) {
// The last snapshot is kept; fireSnapshot is reset so the next request runs.
console.error('[VISOR] snapshot failed', err);
setPropsRef.current({ fireSnapshot: false });
return;
}
setPropsRef.current({
parts: appState.toDict(),
fireSnapshot: false,
Expand Down Expand Up @@ -145,6 +153,7 @@ Visordash.propTypes = {
fireSnapshot: PropTypes.bool,
/**
* Contains the latest snapshot of the parts state, returned from the frontend when `fireSnapshot` is triggered.
* A failed snapshot leaves it unchanged and logs `[VISOR] snapshot failed` to the browser console.
*/
parts: PropTypes.any,
/**
Expand Down
17 changes: 15 additions & 2 deletions src/ansys/visor/viewer/app/trame/server_state_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@

from ansys.visor.viewer.app.trame.async_runner import AsyncRunner
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.renderer.server_renderer import CameraPublisher
from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState
from ansys.visor.viewer.renderer.server_renderer import ViewRecordPublisher

SERVER_CAMERA_STATE_KEY: str = "server_camera_state"
"""State key carrying the server's camera record, as its camelCase fields, or None."""

SERVER_CROSS_SECTION_STATE_KEY: str = "server_cross_section_state"
"""State key carrying the server's cross-section plane record, as ``origin`` and ``normal``, or None."""

SCENE_VERSION_KEY: str = "scene_version"
"""State key counting scene changes; absent until the first change, then 1, 2, ..."""


class ServerStatePublisher(CameraPublisher):
class ServerStatePublisher(ViewRecordPublisher):
"""Writes server-originated state keys from any thread onto the trame event loop."""

_async_runner: AsyncRunner
Expand All @@ -44,6 +48,15 @@ def publish_camera(self, camera_state: VisorCameraState | None) -> None:
camera_record = None if camera_state is None else camera_state.model_dump(by_alias=True)
self._async_runner.call_soon(lambda: self._write(SERVER_CAMERA_STATE_KEY, camera_record))

def publish_cross_section(self, cross_section_state: VisorCrossSectionState | None) -> None:
"""Dump the record by alias on the calling thread, or None, then write server_cross_section_state on the loop thread."""
cross_section_record = (
None if cross_section_state is None else cross_section_state.model_dump(by_alias=True)
)
self._async_runner.call_soon(
lambda: self._write(SERVER_CROSS_SECTION_STATE_KEY, cross_section_record)
)

def bump_scene_version(self) -> None:
"""Increment scene_version on the loop thread: read, add one, write and flush in one callback; callable from any thread.

Expand Down
7 changes: 4 additions & 3 deletions src/ansys/visor/viewer/app/visor_vtk_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->

One bridge serves both: the scene's renderer requests frames through
it, and the app binds it to the view handler when the server is ready.
The publisher writes the renderer's camera record into this viewer's
server state, and the scene_version bump after each scene change.
The publisher writes the renderer's camera and cross-section plane
records into this viewer's server state, and the scene_version bump
after each scene change.
"""
bridge = ServerLoopBridge(self._server_manager.async_runner)
self._state_publisher = ServerStatePublisher(self._server_manager.async_runner, self.server.state)

self._scene = VisorServerScene(
self.server, dark_mode=self._dark_mode, bridge=bridge, camera_publisher=self._state_publisher
self.server, dark_mode=self._dark_mode, bridge=bridge, view_record_publisher=self._state_publisher
)

self._trame_app = ServerApp(
Expand Down
51 changes: 36 additions & 15 deletions src/ansys/visor/viewer/renderer/server_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
Owns an off-screen VTK pipeline and presents each write by asking a
:class:`FrameRequester` for a streamed frame. It never calls ``Render()``
itself: every render happens on the streaming path, under the scene lock.
Each camera write also hands the camera record to a :class:`CameraPublisher`,
since a client of a streamed view has no camera of its own to read.
Each camera and cross-section plane write also hands the record to a
:class:`ViewRecordPublisher`, since a client of a streamed view has no camera
or plane of its own to read.
"""

from __future__ import annotations
Expand All @@ -21,6 +22,7 @@
from vtkmodules.vtkRenderingCore import vtkRenderWindow

from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState
from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer

INITIAL_WINDOW_SIZE: tuple[int, int] = (300, 300)
Expand All @@ -34,33 +36,36 @@ def request_frame(self) -> None:
"""Ask for a streamed frame; callable from any thread."""


class CameraPublisher(Protocol):
"""Where a ServerRenderer publishes its camera record."""
class ViewRecordPublisher(Protocol):
"""Where a ServerRenderer publishes its camera and cross-section plane records."""

def publish_camera(self, camera_state: VisorCameraState | None) -> None:
"""Publish the camera record to clients, or None when there is none; callable from any thread."""

def publish_cross_section(self, cross_section_state: VisorCrossSectionState | None) -> None:
"""Publish the plane record to clients, or None when there is none; callable from any thread."""


class ServerRenderer(VtkPipelineRenderer):
"""Off-screen VTK pipeline whose writes are presented by requesting a streamed frame."""

_background_rgb: tuple[float, float, float]
_frame_requester: FrameRequester
_camera_publisher: CameraPublisher
_view_record_publisher: ViewRecordPublisher
_bounding_box_visible: bool

def __init__(
self,
background_rgb: tuple[float, float, float],
frame_requester: FrameRequester,
camera_publisher: CameraPublisher,
view_record_publisher: ViewRecordPublisher,
) -> None:
"""Build the off-screen pipeline; every write ends in a frame request, every camera write also publishes the record."""
"""Build the off-screen pipeline; every write ends in a frame request, every camera or plane write also publishes the record."""
# All three are read during the base build: the background by
# _initialize_vtk_renderer, the requester and publisher by any publish hook.
self._background_rgb = background_rgb
self._frame_requester = frame_requester
self._camera_publisher = camera_publisher
self._view_record_publisher = view_record_publisher
self._bounding_box_visible = False
super().__init__()
self._cross_section_widget.link_plane_to_handle()
Expand Down Expand Up @@ -102,19 +107,21 @@ def record_pipeline_camera(self) -> None:
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.
"""Record a dragged clip plane through the ``sync_cross_section_plane`` step and publish it; 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.
plane that no drag touched. The step is
:meth:`sync_cross_section_plane`'s without its frame request: 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()))
self._publish_cross_section_record()

# ------------------------------------------------------------------
# IRenderer: wire contract
Expand Down Expand Up @@ -142,6 +149,15 @@ def set_bounding_box_visibility(self, visible: bool) -> None:
self._apply_bounding_box_visibility()
self._frame_requester.request_frame()

def update_bounds(self, bounds: list[float]) -> None:
"""See :meth:`VtkPipelineRenderer.update_bounds`; then publish the plane record, which the first call seeds; requests no frame.

Publishes after the base call, so the first call publishes the seeded
record rather than ``None``.
"""
super().update_bounds(bounds)
self._publish_cross_section_record()

def update_actor_count(self, count: int) -> None:
"""Record the part count and re-apply the remembered outline visibility.

Expand Down Expand Up @@ -183,13 +199,18 @@ def _publish_camera(self) -> None:
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())
"""Hand the camera record to the record publisher; requests no frame."""
self._view_record_publisher.publish_camera(self.get_camera_state())

def _publish_cross_section(self) -> None:
"""Request a frame showing the written plane."""
"""Publish the plane record, then request a frame showing the written plane."""
self._publish_cross_section_record()
self._frame_requester.request_frame()

def _publish_cross_section_record(self) -> None:
"""Hand the plane record to the record publisher; requests no frame."""
self._view_record_publisher.publish_cross_section(self.get_cross_section_plane())

# ------------------------------------------------------------------
# VtkPipelineRenderer: backend hooks
# ------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions src/ansys/visor/viewer/vtk/scene/server_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
if TYPE_CHECKING:
from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge
from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState
from ansys.visor.viewer.renderer.server_renderer import CameraPublisher
from ansys.visor.viewer.renderer.server_renderer import ViewRecordPublisher

BACKGROUND_RGB: dict[bool, tuple[float, float, float]] = {
False: (247 / 255, 247 / 255, 247 / 255),
Expand All @@ -47,10 +47,10 @@ def __init__(
server: Server,
dark_mode: bool,
bridge: ServerLoopBridge,
camera_publisher: CameraPublisher,
view_record_publisher: ViewRecordPublisher,
) -> None:
"""Build the ServerRenderer and the RCA window over this scene's lock."""
renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge, camera_publisher)
renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge, view_record_publisher)
super().__init__(server, dark_mode, renderer=renderer)
self._rca_window = VisorRcaWindow(
VtkRemoteControlledArea(renderer.render_window),
Expand Down
Loading
Loading