diff --git a/examples/00-basic-examples/README.txt b/examples/00-basic-pyvista-examples/README.txt similarity index 100% rename from examples/00-basic-examples/README.txt rename to examples/00-basic-pyvista-examples/README.txt diff --git a/examples/00-basic-examples/clipping_plane.py b/examples/00-basic-pyvista-examples/clipping_plane.py similarity index 100% rename from examples/00-basic-examples/clipping_plane.py rename to examples/00-basic-pyvista-examples/clipping_plane.py diff --git a/examples/00-basic-examples/picker.py b/examples/00-basic-pyvista-examples/picker.py similarity index 100% rename from examples/00-basic-examples/picker.py rename to examples/00-basic-pyvista-examples/picker.py diff --git a/examples/00-basic-examples/plain_usage.py b/examples/00-basic-pyvista-examples/plain_usage.py similarity index 100% rename from examples/00-basic-examples/plain_usage.py rename to examples/00-basic-pyvista-examples/plain_usage.py diff --git a/examples/00-basic-pyvista-examples/remote_trame_view.py b/examples/00-basic-pyvista-examples/remote_trame_view.py new file mode 100644 index 00000000..b2d3c482 --- /dev/null +++ b/examples/00-basic-pyvista-examples/remote_trame_view.py @@ -0,0 +1,70 @@ +# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +.. _ref_remote_trame_view: + +============================= +Use trame as a remote service +============================= + +This example shows how to launch a trame service and use it as a remote service. + +First, we need to launch the trame service. We can do this by running the following code:: + + # import required libraries + from ansys.tools.visualization_interface.backends.pyvista.trame_service import ( + TrameService, + ) + + # create a trame service, in whatever port is available in your system + ts = TrameService(websocket_port=8765) + + # run the service + ts.run() + + +Now, we can send meshes and plotter to the trame service. We can do this by running the following code in a separate terminal:: + + # import required libraries + import time + + import pyvista as pv + + from ansys.tools.visualization_interface.backends.pyvista.trame_remote import ( + send_mesh, + send_pl, + ) + + # create an example plotter + plotter = pv.Plotter() + plotter.add_mesh(pv.Cube()) + + # send some example meshes + send_mesh(pv.Sphere()) + send_mesh(pv.Sphere(center=(3, 0, 0))) + time.sleep(4) + + # if we send a plotter, the previous meshes will be deleted. + send_pl(plotter) + +""" \ No newline at end of file diff --git a/examples/00-basic-examples/using_meshobject.py b/examples/00-basic-pyvista-examples/using_meshobject.py similarity index 100% rename from examples/00-basic-examples/using_meshobject.py rename to examples/00-basic-pyvista-examples/using_meshobject.py diff --git a/pyproject.toml b/pyproject.toml index 866ece20..17912a8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,12 @@ classifiers = [ ] dependencies = [ - "pyvista >= 0.42.0", - "beartype >= 0.17.0", + "pyvista >= 0.42.0,<1", + "beartype >= 0.17.0,<1", + "websockets >= 12.0,<13", + "trame >= 3.6.0,<4", + "trame-vtk >= 2.8.7,<3", + "trame-vuetify >= 2.4.3,<3", ] [project.optional-dependencies] diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py index 16261b6d..8c775bb4 100644 --- a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py +++ b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py @@ -29,7 +29,7 @@ from ansys.tools.visualization_interface import USE_TRAME from ansys.tools.visualization_interface.backends._base import BaseBackend from ansys.tools.visualization_interface.backends.pyvista.pyvista_interface import PyVistaInterface -from ansys.tools.visualization_interface.backends.pyvista.trame_gui import ( +from ansys.tools.visualization_interface.backends.pyvista.trame_local import ( _HAS_TRAME, TrameVisualizer, ) @@ -280,7 +280,7 @@ def compute_edge_object_map(self) -> Dict[pv.Actor, EdgePlot]: """Compute the mapping between plotter actors and ``EdgePlot`` objects. Returns - -------- + ------- Dict[~pyvista.Actor, EdgePlot] Dictionary defining the mapping between plotter actors and ``EdgePlot`` objects. diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/trame_gui.py b/src/ansys/tools/visualization_interface/backends/pyvista/trame_local.py similarity index 100% rename from src/ansys/tools/visualization_interface/backends/pyvista/trame_gui.py rename to src/ansys/tools/visualization_interface/backends/pyvista/trame_local.py diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/trame_remote.py b/src/ansys/tools/visualization_interface/backends/pyvista/trame_remote.py new file mode 100644 index 00000000..69438367 --- /dev/null +++ b/src/ansys/tools/visualization_interface/backends/pyvista/trame_remote.py @@ -0,0 +1,63 @@ +# Copyright (C) 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for trame websocket client functions.""" +import pickle + +from beartype.typing import Union +import pyvista as pv +from websockets.sync.client import connect + + +def send_pl(plotter: pv.Plotter, host: str = "localhost", port: int = 8765): + """Send the plotter meshes to a remote trame service. + + Since plotter can't be pickled, we send the meshes list instead. + + Parameters + ---------- + plotter : pv.Plotter + Plotter to send. + host : str, optional + Websocket host to connect to, by default "localhost". + port : int, optional + Websocket port to connect to, by default 8765. + """ + with connect("ws://" + host + ":" + str(port)) as websocket: + # Plotter can't be pickled, so we send the meshes list instead + meshes_list_pk = pickle.dumps(plotter.meshes) + websocket.send(meshes_list_pk) + +def send_mesh(mesh: Union[pv.PolyData, pv.MultiBlock], host: str = "localhost", port: int = 8765): + """Send a mesh to a remote trame service. + + Parameters + ---------- + mesh : Union[pv.PolyData, pv.MultiBlock] + Mesh to send. + host : str, optional + Websocket host to connect to, by default "localhost". + port : int, optional + Websocket port to connect to, by default 8765. + """ + with connect("ws://" + host + ":" + str(port)) as websocket: + mesh_pk = pickle.dumps(mesh) + websocket.send(mesh_pk) diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/trame_service.py b/src/ansys/tools/visualization_interface/backends/pyvista/trame_service.py new file mode 100644 index 00000000..3af1fb1a --- /dev/null +++ b/src/ansys/tools/visualization_interface/backends/pyvista/trame_service.py @@ -0,0 +1,125 @@ +# Copyright (C) 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Trame service module.""" +import asyncio +import pickle + +import pyvista as pv +from pyvista.trame.ui import plotter_ui +from trame.app import get_server +from trame.ui.vuetify3 import SinglePageLayout +from trame.widgets import vuetify3 +from websockets import WebSocketServerProtocol +from websockets.server import serve + + +class TrameService: + """Trame service class. + + Initializes a trame service where you can send meshes to plot in a trame webview plotter. + + Parameters + ---------- + websocket_host : str, optional + Host where the webserver will listen for new plotters and meshes, by default "localhost". + websocket_port : int, optional + Port where the webserver will listen for new plotters and meshes, by default 8765. + """ + def __init__(self, websocket_host: str = "localhost", websocket_port: int=8765): + """Initialize the trame service.""" + pv.OFF_SCREEN = True + + self._server = get_server() + self._state, self._ctrl = self._server.state, self._server.controller + self._websocket_port = websocket_port + self._websocket_host = websocket_host + # pyvista plotter, we treat it as a view i.e. created once but updated as we see fit. + self._pl = pv.Plotter() + + def clear_plotter(self): + """Clears the web view in the service.""" + self._pl.clear_actors() + self._pl.reset_camera() + + + async def _listener(self, websocket: WebSocketServerProtocol): + """Listens for new meshes and plotters. + + Infinite loop where we wait for new messages from the client. + If we get a list of meshes, we assume it's a scene and clear the previous meshes. + + Parameters + ---------- + websocket : websockets.WebSocketServerProtocol + Websocket where to listen. + """ + async for message in websocket: + obj = pickle.loads(message) + + if isinstance(obj, list): + # if we get a list of meshes, assume it's a scene and clear previous meshes + self.clear_plotter() + for mesh in obj: + self._pl.add_mesh(mesh) + print(mesh) + else: + print(obj) + self._pl.add_mesh(obj) + self._pl.reset_camera() + + async def _webserver(self): + """Starts the webserver for the trame service listener.""" + async with serve(self._listener, self._websocket_host, self._websocket_port): + await asyncio.Future() # run forever + + def set_scene(self): + """Sets the web view scene for the trame service.""" + with SinglePageLayout(self._server) as layout: + with layout.toolbar: + with vuetify3.VBtn(icon=True, click=self.clear_plotter): + vuetify3.VIcon("mdi-trash-can") + with vuetify3.VBtn(icon=True, click=self._ctrl.reset_camera): + vuetify3.VIcon("mdi-crop-free") + + # main view container + with layout.content: + with vuetify3.VContainer( + fluid=True, classes="pa-0 fill-height", style="position: relative;" + ): + view = plotter_ui(self._pl) + # bind plotter methods to the controller method, this way we can + # attach UI elements on it. see buttons above + self._ctrl.view_update = view.update + self._ctrl.reset_camera = view.reset_camera + + + async def _launch_trame_service(self): + """Launches the trame service.""" + self.set_scene() + trame_coroutine = self._server.start(exec_mode="coroutine") + webserver_coroutine = asyncio.create_task(self._webserver()) + await asyncio.gather(trame_coroutine, webserver_coroutine) + + def run(self): + """Start the trame web view and the websocket services.""" + asyncio.run(self._launch_trame_service())