diff --git a/doc/source/_static/omniverse_app_exts.png b/doc/source/_static/omniverse_app_exts.png new file mode 100644 index 00000000000..e01dfc79d03 Binary files /dev/null and b/doc/source/_static/omniverse_app_exts.png differ diff --git a/doc/source/_static/omniverse_app_paths.png b/doc/source/_static/omniverse_app_paths.png new file mode 100644 index 00000000000..cc103f87bcb Binary files /dev/null and b/doc/source/_static/omniverse_app_paths.png differ diff --git a/doc/source/user_guide/omniverse_info.rst b/doc/source/user_guide/omniverse_info.rst index ee360ea7933..1fecbacaff5 100644 --- a/doc/source/user_guide/omniverse_info.rst +++ b/doc/source/user_guide/omniverse_info.rst @@ -123,8 +123,18 @@ scene to Omniverse. ``git://github.com/ansys/pyensight.git?branch=main&dir=exts``. -Developers: Running a "dev" build from the Command Line -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Developers: Running development builds +-------------------------------------- + +There are several different ways for developers working on these +features to debug and test them. There is a command line approach +perhaps more suited to the pyensight developer and there is an +Omniverse tool GUI approach that can be useful when looking to +develop/extend the UI kits. + + +From the Command Line +^^^^^^^^^^^^^^^^^^^^^ Omniverse kits can be run as command line tools and the ``ansys.geometry.service`` is designed to support this mode @@ -137,15 +147,15 @@ using the ``Settings`` option: .. image:: /_static/omniverse_create_location.png Consider an example where the create app has been installed and the -file ``C:\Users\me\AppData\Local\ov\pkg\create-2023.2.5\kit.bat`` +file ``C:\Users\user1\AppData\Local\ov\pkg\create-2023.2.5\kit.bat`` exists. A copy of the pyensight repo is located and built here: ``D:\repos\pyensight``. With these conditions, one can run the extension from the command line like this: .. code-block:: bat - cd "C:\Users\me\AppData\Local\ov\pkg\create-2023.2.5" - .\kit.bat --ext-folder "D:\repos\pyensight\exts" --enable ansys.geometry.service --/exts/ansys.geometry.service/help=1 + cd "C:\Users\user1\AppData\Local\ov\pkg\create-2023.2.5" + .\kit.bat --ext-folder "D:\repos\pyensight\src\ansys\pyensight\core\exts" --enable ansys.geometry.service --/exts/ansys.geometry.service/help=1 Will generate the following output in the logs: @@ -172,17 +182,29 @@ Will generate the following output in the logs: Documenting the various kit command line options. Using the ``run=1`` option will launch the server from -from the command line. This version of the service will be run using the version of the pyensight wheel +from the command line. This version of the service will be run using the version of the pyensight module installed in the specified ``--ext-folder``. When run as above, the service will use the -latest release of the ansys.pyensight.core wheel. If a local build is located here: -``D:\repos\pyensight\dist\ansys_pyensight_core-0.9.0.dev0-py3-none-any.whl`` it can be -used in the above kit by installing it into the Omniverse kit Python: +latest released of the ansys.pyensight.core wheel. It is important the the ``--ext-folder`` option +point to the ``exts`` directory inside of the ``ansys\pyensight\core`` directories as this will +cause the kit to use the ``ansys.pyensight.core`` module from the directories above the kit +instead of the any version installed in the kit Python itself. -.. code-block:: bat +From an Omniverse Application GUI +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This approach is very similar to the CLI approach in that one needs to get the GUI application +to use the kit from either a source code checkout or perhaps from a local EnSight install. +In either case, the key point is to add the same directory pointed out earlier to the GUI application. + +For example, if one has a copy of the pyensight repo checked out as in the previous CLI example, the +key directory will be ``D:\repos\pyensight\src\ansys\pyensight\core\exts``. This pathname can be +added to the extensions path in an application like "Composer" through this GUI: - .\kit\python.bat -m pip install D:\repos\pyensight\dist\ansys_pyensight_core-0.9.0.dev0-py3-none-any.whl +.. image:: /_static/omniverse_app_paths.png +With the path in place, the kits will show up in the Third-party extensions list and can be +activated in the GUI. -This version will be used instead of the older version in the PyPi repository. +.. image:: /_static/omniverse_app_exts.png diff --git a/exts/ansys.geometry.service/ansys/geometry/service/extension.py b/exts/ansys.geometry.service/ansys/geometry/service/extension.py index d8b5ebfbe56..f75fa7de364 100644 --- a/exts/ansys.geometry.service/ansys/geometry/service/extension.py +++ b/exts/ansys.geometry.service/ansys/geometry/service/extension.py @@ -1,9 +1,13 @@ +from importlib import reload +import json import logging import os import subprocess import sys +import tempfile from typing import Optional from urllib.parse import urlparse +import uuid import carb.settings import omni.ext @@ -11,13 +15,67 @@ import omni.kit.pipapi import psutil +""" +For development environments, it can be useful to use a "future versioned" +build of ansys-pyensight-core. If the appropriate environmental variables +are set, a pip install from another repository can be forced. +""" +extra_args = [] +if "ANSYS_PYPI_INDEX_URL" in os.environ: + extra_args.append(os.environ["ANSYS_PYPI_INDEX_URL"]) + +if os.environ.get("ANSYS_PYPI_REINSTALL", "") == "1": + extra_args.extend(["--upgrade", "--no-deps", "--no-cache-dir", "--force-reinstall", "--pre"]) + + logging.warning("ansys.geometry.server - Forced reinstall ansys-pyensight-core") + omni.kit.pipapi.install("ansys-pyensight-core", extra_args=extra_args) + try: + # Checking to see if we need to install the module import ansys.pyensight.core import ansys.pyensight.core.utils.dsg_server as tmp_dsg_server # noqa: F401 import ansys.pyensight.core.utils.omniverse_dsg_server as tmp_ov_dsg_server # noqa: F401 except ModuleNotFoundError: logging.warning("ansys.geometry.server - Installing ansys-pyensight-core") - omni.kit.pipapi.install("ansys-pyensight-core") + omni.kit.pipapi.install("ansys-pyensight-core", extra_args=extra_args) + +""" +If we have a local copy of the module, the above installed the correct +dependencies, but we want to use the local copy. Do this by prefixing +the path and (re)load the modules. The pyensight wheel includes the +following for this file: +ansys\pyensight\core\exts\ansys.geometry.service\ansys\geometry\service\extension.py +""" +kit_dir = __file__ +for _ in range(5): + kit_dir = os.path.dirname(kit_dir) +""" +At this point, the name should be: {something}\ansys\pyensight\core\exts +Check for the fragments in the right order. +""" +offsets = [] +for name in ["ansys", "pyensight", "core", "exts"]: + offsets.append(kit_dir.find(name)) +# if the order of the names in correct and there is no -1 in the offsets, we found it +if (sorted(offsets) == offsets) and (sorted(offsets)[0] != -1): + # name of 'ansys/pyensight/core' directory. We need three levels above. + name = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(kit_dir)))) + sys.path.insert(0, name) + logging.info(f"Using ansys.pyensight.core from: {name}") + +# at this point, we may need to repeat the imports that make have failed earlier +import ansys.pyensight.core # noqa: F811, E402 +import ansys.pyensight.core.utils.dsg_server as dsg_server # noqa: E402 +import ansys.pyensight.core.utils.omniverse_dsg_server as ov_dsg_server # noqa: E402 + +# force a reload if we changed the path or had a partial failure that lead +# to a pipapi install. +_ = reload(ansys.pyensight.core) +_ = reload(ansys.pyensight.core.utils) +_ = reload(ansys.pyensight.core.utils.dsg_server) +_ = reload(ansys.pyensight.core.utils.omniverse_dsg_server) + +logging.warning(f"Using ansys.pyensight.core from: {ansys.pyensight.core.__file__}") def find_kit_filename() -> Optional[str]: @@ -93,6 +151,12 @@ def __init__(self, *args, **kwargs) -> None: self._version = "unknown" self._shutdown = False self._server_process = None + self._status_filename: str = "" + + @property + def pyensight_version(self) -> str: + """The ansys.pyensight.core version""" + return ansys.pyensight.core.VERSION @property def dsg_uri(self) -> str: @@ -344,9 +408,54 @@ def launch_server(self) -> None: cmd.append(f"--/exts/ansys.geometry.service/dsgUrl={self.dsg_uri}") cmd.append("--/exts/ansys.geometry.service/run=1") env_vars = os.environ.copy() + # we are launching the kit from an Omniverse app. In this case, we + # inform the kit instance of: + # (1) the name of the "server status" file, if any + self._new_status_file() + env_vars["ANSYS_OV_SERVER_STATUS_FILENAME"] = self._status_filename working_dir = os.path.join(os.path.dirname(ansys.pyensight.core.__file__), "utils") self._server_process = subprocess.Popen(cmd, close_fds=True, env=env_vars, cwd=working_dir) + def _new_status_file(self, new=True) -> None: + """ + Remove any existing status file and create a new one if requested. + + Parameters + ---------- + new : bool + If True, create a new status file. + """ + if self._status_filename: + try: + os.remove(self._status_filename) + except OSError: + self.warning(f"Unable to delete the status file: {self._status_filename}") + self._status_filename = "" + if new: + self._status_filename = os.path.join( + tempfile.gettempdir(), str(uuid.uuid1()) + "_gs_status.txt" + ) + + def read_status_file(self) -> dict: + """Read the status file and return its contents as a dictionary. + + Note: this can fail if the file is being written to when this call is made, so expect + failures. + + Returns + ------- + Optional[dict] + A dictionary with the fields 'status', 'start_time', 'processed_buffers', 'total_buffers' or empty + """ + if not self._status_filename: + return {} + try: + with open(self._status_filename, "r") as status_file: + data = json.load(status_file) + except Exception: + return {} + return data + def run_server(self) -> None: """ Run a DSG to Omniverse server in process. @@ -354,17 +463,6 @@ def run_server(self) -> None: Note: this method does not return until the DSG connection is dropped or self.stop_server() has been called. """ - try: - import ansys.pyensight.core.utils.dsg_server as dsg_server - import ansys.pyensight.core.utils.omniverse_dsg_server as ov_dsg_server - except ImportError as e: - self.error(f"Unable to load DSG service core: {str(e)}") - return - - # Note: This is temporary. The correct fix will be included in - # the pyensight 0.8.5 wheel. The OmniverseWrapper assumes the CWD - # to be the directory with the "resource" directory. - os.chdir(os.path.dirname(ov_dsg_server.__file__)) # Build the Omniverse connection omni_link = ov_dsg_server.OmniverseWrapper(path=self._omni_uri, verbose=1) diff --git a/exts/ansys.geometry.service/config/extension.toml b/exts/ansys.geometry.service/config/extension.toml index 751305152fd..d2c8b9547f9 100644 --- a/exts/ansys.geometry.service/config/extension.toml +++ b/exts/ansys.geometry.service/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.8.7" +version = "0.9.0-dev0" # Lists people or organizations that are considered the "authors" of the package. authors = ["ANSYS"] diff --git a/exts/ansys.geometry.service/docs/CHANGELOG.md b/exts/ansys.geometry.service/docs/CHANGELOG.md index 2abc3e1bc6a..12bd53edb44 100644 --- a/exts/ansys.geometry.service/docs/CHANGELOG.md +++ b/exts/ansys.geometry.service/docs/CHANGELOG.md @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.8.9] - 2024-07-25 +- Improved status feedback + ## [0.8.7] - 2024-07-12 - Support for time varying data and temporal scaling diff --git a/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py b/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py index f1f8acb2db7..51a8659a50c 100644 --- a/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py +++ b/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py @@ -1,4 +1,6 @@ import logging +import threading +import time from typing import Any, Optional from urllib.parse import urlparse @@ -111,16 +113,28 @@ def on_startup(self, ext_id: str) -> None: if self.service is None: self.error("Unable to find ansys.geometry.service instance") self.build_ui() + self._update_callback() + + def _update_callback(self) -> None: self.update_ui() + threading.Timer(0.5, self._update_callback).start() def update_ui(self) -> None: + status = self.service.read_status_file() if self._connected: self._connect_w.text = "Disconnect from DSG Server" - self._label_w.text = f"Connected to: {self.service.dsg_uri}" + tmp = f"Connected to: {self.service.dsg_uri}" + if status.get("status", "idle") == "working": + count = status.get("processed_buffers", 0) + total = status.get("total_buffers", 0) + dt = time.time() - status.get("start_time", 0.0) + tmp = f"Transfer: {count} of {total} : {dt:.2f}s" + self._label_w.text = tmp else: self._connect_w.text = "Connect to DSG Server" self._label_w.text = "No connected DSG server" - self._update_w.enabled = self._connected + self._update_w.enabled = self._connected and (status.get("status", "idle") == "idle") + self._connect_w.enabled = status.get("status", "idle") == "idle" self._temporal_w.enabled = True self._vrmode_w.enabled = not self._connected self._normalize_w.enabled = not self._connected @@ -130,7 +144,7 @@ def update_ui(self) -> None: self._omni_uri_w.enabled = not self._connected def build_ui(self) -> None: - self._window = ui.Window("ANSYS Geometry Service") + self._window = ui.Window(f"ANSYS Geometry Service ({self.service.pyensight_version})") with self._window.frame: with ui.VStack(height=0, spacing=5): self._label_w = ui.Label("No connected DSG server") diff --git a/exts/ansys.geometry.serviceui/config/extension.toml b/exts/ansys.geometry.serviceui/config/extension.toml index bb506207f9e..b96cfb35c96 100644 --- a/exts/ansys.geometry.serviceui/config/extension.toml +++ b/exts/ansys.geometry.serviceui/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.8.7" +version = "0.9.0-dev0" # Lists people or organizations that are considered the "authors" of the package. authors = ["ANSYS"] diff --git a/exts/ansys.geometry.serviceui/docs/CHANGELOG.md b/exts/ansys.geometry.serviceui/docs/CHANGELOG.md index 2abc3e1bc6a..12bd53edb44 100644 --- a/exts/ansys.geometry.serviceui/docs/CHANGELOG.md +++ b/exts/ansys.geometry.serviceui/docs/CHANGELOG.md @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.8.9] - 2024-07-25 +- Improved status feedback + ## [0.8.7] - 2024-07-12 - Support for time varying data and temporal scaling diff --git a/pyproject.toml b/pyproject.toml index 1a0532fe3d1..87a8fc15acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" [project] name = "ansys-pyensight-core" -version = "0.9.0.dev0" +version = "0.9.0-dev0" description = "A python wrapper for Ansys EnSight" readme = "README.rst" requires-python = ">=3.9,<4" diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/extension.py b/src/ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/extension.py index d8b5ebfbe56..f75fa7de364 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/extension.py +++ b/src/ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/extension.py @@ -1,9 +1,13 @@ +from importlib import reload +import json import logging import os import subprocess import sys +import tempfile from typing import Optional from urllib.parse import urlparse +import uuid import carb.settings import omni.ext @@ -11,13 +15,67 @@ import omni.kit.pipapi import psutil +""" +For development environments, it can be useful to use a "future versioned" +build of ansys-pyensight-core. If the appropriate environmental variables +are set, a pip install from another repository can be forced. +""" +extra_args = [] +if "ANSYS_PYPI_INDEX_URL" in os.environ: + extra_args.append(os.environ["ANSYS_PYPI_INDEX_URL"]) + +if os.environ.get("ANSYS_PYPI_REINSTALL", "") == "1": + extra_args.extend(["--upgrade", "--no-deps", "--no-cache-dir", "--force-reinstall", "--pre"]) + + logging.warning("ansys.geometry.server - Forced reinstall ansys-pyensight-core") + omni.kit.pipapi.install("ansys-pyensight-core", extra_args=extra_args) + try: + # Checking to see if we need to install the module import ansys.pyensight.core import ansys.pyensight.core.utils.dsg_server as tmp_dsg_server # noqa: F401 import ansys.pyensight.core.utils.omniverse_dsg_server as tmp_ov_dsg_server # noqa: F401 except ModuleNotFoundError: logging.warning("ansys.geometry.server - Installing ansys-pyensight-core") - omni.kit.pipapi.install("ansys-pyensight-core") + omni.kit.pipapi.install("ansys-pyensight-core", extra_args=extra_args) + +""" +If we have a local copy of the module, the above installed the correct +dependencies, but we want to use the local copy. Do this by prefixing +the path and (re)load the modules. The pyensight wheel includes the +following for this file: +ansys\pyensight\core\exts\ansys.geometry.service\ansys\geometry\service\extension.py +""" +kit_dir = __file__ +for _ in range(5): + kit_dir = os.path.dirname(kit_dir) +""" +At this point, the name should be: {something}\ansys\pyensight\core\exts +Check for the fragments in the right order. +""" +offsets = [] +for name in ["ansys", "pyensight", "core", "exts"]: + offsets.append(kit_dir.find(name)) +# if the order of the names in correct and there is no -1 in the offsets, we found it +if (sorted(offsets) == offsets) and (sorted(offsets)[0] != -1): + # name of 'ansys/pyensight/core' directory. We need three levels above. + name = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(kit_dir)))) + sys.path.insert(0, name) + logging.info(f"Using ansys.pyensight.core from: {name}") + +# at this point, we may need to repeat the imports that make have failed earlier +import ansys.pyensight.core # noqa: F811, E402 +import ansys.pyensight.core.utils.dsg_server as dsg_server # noqa: E402 +import ansys.pyensight.core.utils.omniverse_dsg_server as ov_dsg_server # noqa: E402 + +# force a reload if we changed the path or had a partial failure that lead +# to a pipapi install. +_ = reload(ansys.pyensight.core) +_ = reload(ansys.pyensight.core.utils) +_ = reload(ansys.pyensight.core.utils.dsg_server) +_ = reload(ansys.pyensight.core.utils.omniverse_dsg_server) + +logging.warning(f"Using ansys.pyensight.core from: {ansys.pyensight.core.__file__}") def find_kit_filename() -> Optional[str]: @@ -93,6 +151,12 @@ def __init__(self, *args, **kwargs) -> None: self._version = "unknown" self._shutdown = False self._server_process = None + self._status_filename: str = "" + + @property + def pyensight_version(self) -> str: + """The ansys.pyensight.core version""" + return ansys.pyensight.core.VERSION @property def dsg_uri(self) -> str: @@ -344,9 +408,54 @@ def launch_server(self) -> None: cmd.append(f"--/exts/ansys.geometry.service/dsgUrl={self.dsg_uri}") cmd.append("--/exts/ansys.geometry.service/run=1") env_vars = os.environ.copy() + # we are launching the kit from an Omniverse app. In this case, we + # inform the kit instance of: + # (1) the name of the "server status" file, if any + self._new_status_file() + env_vars["ANSYS_OV_SERVER_STATUS_FILENAME"] = self._status_filename working_dir = os.path.join(os.path.dirname(ansys.pyensight.core.__file__), "utils") self._server_process = subprocess.Popen(cmd, close_fds=True, env=env_vars, cwd=working_dir) + def _new_status_file(self, new=True) -> None: + """ + Remove any existing status file and create a new one if requested. + + Parameters + ---------- + new : bool + If True, create a new status file. + """ + if self._status_filename: + try: + os.remove(self._status_filename) + except OSError: + self.warning(f"Unable to delete the status file: {self._status_filename}") + self._status_filename = "" + if new: + self._status_filename = os.path.join( + tempfile.gettempdir(), str(uuid.uuid1()) + "_gs_status.txt" + ) + + def read_status_file(self) -> dict: + """Read the status file and return its contents as a dictionary. + + Note: this can fail if the file is being written to when this call is made, so expect + failures. + + Returns + ------- + Optional[dict] + A dictionary with the fields 'status', 'start_time', 'processed_buffers', 'total_buffers' or empty + """ + if not self._status_filename: + return {} + try: + with open(self._status_filename, "r") as status_file: + data = json.load(status_file) + except Exception: + return {} + return data + def run_server(self) -> None: """ Run a DSG to Omniverse server in process. @@ -354,17 +463,6 @@ def run_server(self) -> None: Note: this method does not return until the DSG connection is dropped or self.stop_server() has been called. """ - try: - import ansys.pyensight.core.utils.dsg_server as dsg_server - import ansys.pyensight.core.utils.omniverse_dsg_server as ov_dsg_server - except ImportError as e: - self.error(f"Unable to load DSG service core: {str(e)}") - return - - # Note: This is temporary. The correct fix will be included in - # the pyensight 0.8.5 wheel. The OmniverseWrapper assumes the CWD - # to be the directory with the "resource" directory. - os.chdir(os.path.dirname(ov_dsg_server.__file__)) # Build the Omniverse connection omni_link = ov_dsg_server.OmniverseWrapper(path=self._omni_uri, verbose=1) diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.service/config/extension.toml b/src/ansys/pyensight/core/exts/ansys.geometry.service/config/extension.toml index 751305152fd..d2c8b9547f9 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.service/config/extension.toml +++ b/src/ansys/pyensight/core/exts/ansys.geometry.service/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.8.7" +version = "0.9.0-dev0" # Lists people or organizations that are considered the "authors" of the package. authors = ["ANSYS"] diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.service/docs/CHANGELOG.md b/src/ansys/pyensight/core/exts/ansys.geometry.service/docs/CHANGELOG.md index 2abc3e1bc6a..12bd53edb44 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.service/docs/CHANGELOG.md +++ b/src/ansys/pyensight/core/exts/ansys.geometry.service/docs/CHANGELOG.md @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.8.9] - 2024-07-25 +- Improved status feedback + ## [0.8.7] - 2024-07-12 - Support for time varying data and temporal scaling diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py index f1f8acb2db7..51a8659a50c 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py +++ b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py @@ -1,4 +1,6 @@ import logging +import threading +import time from typing import Any, Optional from urllib.parse import urlparse @@ -111,16 +113,28 @@ def on_startup(self, ext_id: str) -> None: if self.service is None: self.error("Unable to find ansys.geometry.service instance") self.build_ui() + self._update_callback() + + def _update_callback(self) -> None: self.update_ui() + threading.Timer(0.5, self._update_callback).start() def update_ui(self) -> None: + status = self.service.read_status_file() if self._connected: self._connect_w.text = "Disconnect from DSG Server" - self._label_w.text = f"Connected to: {self.service.dsg_uri}" + tmp = f"Connected to: {self.service.dsg_uri}" + if status.get("status", "idle") == "working": + count = status.get("processed_buffers", 0) + total = status.get("total_buffers", 0) + dt = time.time() - status.get("start_time", 0.0) + tmp = f"Transfer: {count} of {total} : {dt:.2f}s" + self._label_w.text = tmp else: self._connect_w.text = "Connect to DSG Server" self._label_w.text = "No connected DSG server" - self._update_w.enabled = self._connected + self._update_w.enabled = self._connected and (status.get("status", "idle") == "idle") + self._connect_w.enabled = status.get("status", "idle") == "idle" self._temporal_w.enabled = True self._vrmode_w.enabled = not self._connected self._normalize_w.enabled = not self._connected @@ -130,7 +144,7 @@ def update_ui(self) -> None: self._omni_uri_w.enabled = not self._connected def build_ui(self) -> None: - self._window = ui.Window("ANSYS Geometry Service") + self._window = ui.Window(f"ANSYS Geometry Service ({self.service.pyensight_version})") with self._window.frame: with ui.VStack(height=0, spacing=5): self._label_w = ui.Label("No connected DSG server") diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/config/extension.toml b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/config/extension.toml index bb506207f9e..b96cfb35c96 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/config/extension.toml +++ b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.8.7" +version = "0.9.0-dev0" # Lists people or organizations that are considered the "authors" of the package. authors = ["ANSYS"] diff --git a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/CHANGELOG.md b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/CHANGELOG.md index 2abc3e1bc6a..12bd53edb44 100644 --- a/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/CHANGELOG.md +++ b/src/ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/CHANGELOG.md @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.8.9] - 2024-07-25 +- Improved status feedback + ## [0.8.7] - 2024-07-12 - Support for time varying data and temporal scaling diff --git a/src/ansys/pyensight/core/utils/dsg_server.py b/src/ansys/pyensight/core/utils/dsg_server.py index 134e0c9af40..77b11d26df7 100644 --- a/src/ansys/pyensight/core/utils/dsg_server.py +++ b/src/ansys/pyensight/core/utils/dsg_server.py @@ -1,8 +1,10 @@ +import json import logging import os import queue import sys import threading +import time from typing import Any, Dict, List, Optional from ansys.api.pyensight.v0 import dynamic_scene_graph_pb2 @@ -533,6 +535,9 @@ def __init__( self._scene_bounds: Optional[List] = None self._cur_timeline: List = [0.0, 0.0] # Start/End time for current update self._callback_handler.session = self + # log any status changes to this file. external apps will be monitoring + self._status_file = os.environ.get("ANSYS_OV_SERVER_STATUS_FILENAME", "") + self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0) @property def scene_bounds(self) -> Optional[List]: @@ -641,6 +646,38 @@ def is_shutdown(self): """Check the service shutdown request status""" return self._shutdown + def _update_status_file(self, timed: bool = False): + """ + Update the status file contents. The status file will contain the + following json object, stored as: self._status + + { + 'status' : "working|idle", + 'start_time' : timestamp_of_update_begin, + 'processed_buffers' : number_of_protobuffers_processed, + 'total_buffers' : number_of_protobuffers_total, + } + + Parameters + ---------- + timed : bool, optional: + if True, only update every second. + + """ + if self._status_file: + current_time = time.time() + if timed: + last_time = self._status.get("last_time", 0.0) + if current_time - last_time < 1.0: # type: ignore + return + self._status["last_time"] = current_time + try: + message = json.dumps(self._status) + with open(self._status_file, "w") as status_file: + status_file.write(message) + except IOError: + pass # Note failure is expected here in some cases + def request_an_update(self, animation: bool = False, allow_spontaneous: bool = True) -> None: """Start a DSG update Send a command to the DSG protocol to "init" an update. @@ -713,12 +750,21 @@ def handle_one_update(self) -> None: self._mesh_block_count = 0 # reset when a new group shows up self._callback_handler.begin_update() + # Update our status + self._status = dict( + status="working", start_time=time.time(), processed_buffers=1, total_buffers=1 + ) + self._update_status_file() + # handle the various commands until UPDATE_SCENE_END cmd = self._get_next_message() while (cmd is not None) and ( cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_END ): self._handle_update_command(cmd) + self._status["processed_buffers"] += 1 # type: ignore + self._status["total_buffers"] = self._status["processed_buffers"] + self._message_queue.qsize() # type: ignore + self._update_status_file(timed=True) cmd = self._get_next_message() # Flush the last part @@ -726,6 +772,10 @@ def handle_one_update(self) -> None: self._callback_handler.end_update() + # Update our status + self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0) + self._update_status_file() + def _handle_update_command(self, cmd: dynamic_scene_graph_pb2.SceneUpdateCommand) -> None: """Dispatch out a scene update command to the proper handler diff --git a/src/ansys/pyensight/core/utils/omniverse.py b/src/ansys/pyensight/core/utils/omniverse.py index 0ec38501402..9341bf4d201 100644 --- a/src/ansys/pyensight/core/utils/omniverse.py +++ b/src/ansys/pyensight/core/utils/omniverse.py @@ -1,9 +1,12 @@ import glob +import json import os import subprocess import sys +import tempfile from types import ModuleType from typing import TYPE_CHECKING, Optional, Union +import uuid import psutil @@ -52,6 +55,7 @@ def __init__(self, interface: Union["ensight_api.ensight", "ensight"]): self._ensight = interface self._server_pid: Optional[int] = None self._interpreter: str = "" + self._status_filename: str = "" @staticmethod def find_kit_filename(fallback_directory: Optional[str] = None) -> Optional[str]: @@ -230,7 +234,8 @@ def create_connection( # Launch the server via the 'ansys.geometry.service' kit dsg_uri = f"grpc://{hostname}:{port}" - kit_dir = os.path.join(os.path.dirname(ansys.pyensight.core.__file__), "exts") + pyensight_core_dir = os.path.dirname(ansys.pyensight.core.__file__) + kit_dir = os.path.join(pyensight_core_dir, "exts") cmd = [self._interpreter] cmd.extend(["--ext-folder", kit_dir]) cmd.extend(["--enable", "ansys.geometry.service"]) @@ -248,10 +253,55 @@ def create_connection( cmd.append(f"--/exts/ansys.geometry.service/dsgUrl={dsg_uri}") cmd.append("--/exts/ansys.geometry.service/run=1") env_vars = os.environ.copy() - working_dir = os.path.join(os.path.dirname(ansys.pyensight.core.__file__), "utils") + # we are launching the kit from EnSight or PyEnSight. In these cases, we + # inform the kit instance of: + # (1) the name of the "server status" file, if any + self._new_status_file() + env_vars["ANSYS_OV_SERVER_STATUS_FILENAME"] = self._status_filename + working_dir = os.path.join(pyensight_core_dir, "utils") process = subprocess.Popen(cmd, close_fds=True, env=env_vars, cwd=working_dir) self._server_pid = process.pid + def _new_status_file(self, new=True) -> None: + """ + Remove any existing status file and create a new one if requested. + + Parameters + ---------- + new : bool + If True, create a new status file. + """ + if self._status_filename: + try: + os.remove(self._status_filename) + except OSError: + pass + self._status_filename = "" + if new: + self._status_filename = os.path.join( + tempfile.gettempdir(), str(uuid.uuid1()) + "_gs_status.txt" + ) + + def read_status_file(self) -> dict: + """Read the status file and return its contents as a dictionary. + + Note: this can fail if the file is being written to when this call is made, so expect + failures. + + Returns + ------- + Optional[dict] + A dictionary with the fields 'status', 'start_time', 'processed_buffers', 'total_buffers' or empty + """ + if not self._status_filename: + return {} + try: + with open(self._status_filename, "r") as status_file: + data = json.load(status_file) + except Exception: + return {} + return data + def close_connection(self) -> None: """Shut down the open EnSight dsg -> omniverse server @@ -275,6 +325,7 @@ def close_connection(self) -> None: except psutil.NoSuchProcess: pass self._server_pid = None + self._new_status_file(new=False) def update(self, temporal: bool = False) -> None: """Update the geometry in Omniverse diff --git a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py index c6c8345686e..f7615484d6f 100644 --- a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py +++ b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py @@ -24,7 +24,6 @@ # ############################################################################### -import argparse import logging import math import os @@ -36,8 +35,8 @@ import png from pxr import Gf, Sdf, Usd, UsdGeom, UsdLux, UsdShade -sys.path.append(os.path.dirname(__file__)) -from dsg_server import DSGSession, Part, UpdateHandler # noqa: E402 +sys.path.insert(0, os.path.dirname(__file__)) +from dsg_server import Part, UpdateHandler # noqa: E402 class OmniverseWrapper: @@ -593,91 +592,6 @@ def uploadMaterial(self): fullpath = os.path.join(os.path.dirname(__file__), "resources", "Materials") omni.client.copy(fullpath, uriPath) - def createMaterial(self, mesh): - # Create a material instance for this in USD - materialName = "Fieldstone" - newMat = UsdShade.Material.Define(self._stage, "/Root/Looks/Fieldstone") - - matPath = "/Root/Looks/Fieldstone" - - # MDL Shader - # Create the MDL shader - mdlShader = UsdShade.Shader.Define(self._stage, matPath + "/Fieldstone") - mdlShader.CreateIdAttr("mdlMaterial") - - mdlShaderModule = "./Materials/Fieldstone.mdl" - mdlShader.SetSourceAsset(mdlShaderModule, "mdl") - # mdlShader.GetPrim().CreateAttribute("info:mdl:sourceAsset:subIdentifier", - # Sdf.ValueTypeNames.Token, True).Set(materialName) - # mdlOutput = newMat.CreateSurfaceOutput("mdl") - # mdlOutput.ConnectToSource(mdlShader, "out") - mdlShader.SetSourceAssetSubIdentifier(materialName, "mdl") - shaderOutput = mdlShader.CreateOutput("out", Sdf.ValueTypeNames.Token) - shaderOutput.SetRenderType("material") - newMat.CreateSurfaceOutput("mdl").ConnectToSource(shaderOutput) - newMat.CreateDisplacementOutput("mdl").ConnectToSource(shaderOutput) - newMat.CreateVolumeOutput("mdl").ConnectToSource(shaderOutput) - - # USD Preview Surface Shaders - - # Create the "USD Primvar reader for float2" shader - primStShader = UsdShade.Shader.Define(self._stage, matPath + "/PrimST") - primStShader.CreateIdAttr("UsdPrimvarReader_float2") - primStShader.CreateOutput("result", Sdf.ValueTypeNames.Float2) - primStShader.CreateInput("varname", Sdf.ValueTypeNames.Token).Set("st") - - # Create the "Diffuse Color Tex" shader - diffuseColorShader = UsdShade.Shader.Define(self._stage, matPath + "/DiffuseColorTex") - diffuseColorShader.CreateIdAttr("UsdUVTexture") - texInput = diffuseColorShader.CreateInput("file", Sdf.ValueTypeNames.Asset) - texInput.Set("./Materials/Fieldstone/Fieldstone_BaseColor.png") - texInput.GetAttr().SetColorSpace("RGB") - diffuseColorShader.CreateInput("st", Sdf.ValueTypeNames.Float2).ConnectToSource( - primStShader.CreateOutput("result", Sdf.ValueTypeNames.Float2) - ) - diffuseColorShaderOutput = diffuseColorShader.CreateOutput("rgb", Sdf.ValueTypeNames.Float3) - - # Create the "Normal Tex" shader - normalShader = UsdShade.Shader.Define(self._stage, matPath + "/NormalTex") - normalShader.CreateIdAttr("UsdUVTexture") - normalTexInput = normalShader.CreateInput("file", Sdf.ValueTypeNames.Asset) - normalTexInput.Set("./Materials/Fieldstone/Fieldstone_N.png") - normalTexInput.GetAttr().SetColorSpace("RAW") - normalShader.CreateInput("st", Sdf.ValueTypeNames.Float2).ConnectToSource( - primStShader.CreateOutput("result", Sdf.ValueTypeNames.Float2) - ) - normalShaderOutput = normalShader.CreateOutput("rgb", Sdf.ValueTypeNames.Float3) - - # Create the USD Preview Surface shader - usdPreviewSurfaceShader = UsdShade.Shader.Define(self._stage, matPath + "/PreviewSurface") - usdPreviewSurfaceShader.CreateIdAttr("UsdPreviewSurface") - diffuseColorInput = usdPreviewSurfaceShader.CreateInput( - "diffuseColor", Sdf.ValueTypeNames.Color3f - ) - diffuseColorInput.ConnectToSource(diffuseColorShaderOutput) - normalInput = usdPreviewSurfaceShader.CreateInput("normal", Sdf.ValueTypeNames.Normal3f) - normalInput.ConnectToSource(normalShaderOutput) - - # Set the linkage between material and USD Preview surface shader - # usdPreviewSurfaceOutput = newMat.CreateSurfaceOutput() - # usdPreviewSurfaceOutput.ConnectToSource(usdPreviewSurfaceShader, "surface") - # UsdShade.MaterialBindingAPI(mesh).Bind(newMat) - - usdPreviewSurfaceShaderOutput = usdPreviewSurfaceShader.CreateOutput( - "surface", Sdf.ValueTypeNames.Token - ) - usdPreviewSurfaceShaderOutput.SetRenderType("material") - newMat.CreateSurfaceOutput().ConnectToSource(usdPreviewSurfaceShaderOutput) - - UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(newMat) - - # Create a distant light in the scene. - def createDistantLight(self): - newLight = UsdLux.DistantLight.Define(self._stage, "/Root/DistantLight") - newLight.CreateAngleAttr(0.53) - newLight.CreateColorAttr(Gf.Vec3f(1.0, 1.0, 0.745)) - newLight.CreateIntensityAttr(500.0) - # Create a dome light in the scene. def createDomeLight(self, texturePath): newLight = UsdLux.DomeLight.Define(self._stage, "/Root/DomeLight") @@ -827,141 +741,3 @@ def end_update(self) -> None: super().end_update() # Stage update complete self._omni.save_stage() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Python Omniverse EnSight Dynamic Scene Graph Client", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument( - "--path", - action="store", - default="omniverse://localhost/Users/test", - help="Omniverse pathname. Default=omniverse://localhost/Users/test", - ) - parser.add_argument( - "--port", - metavar="ensight_grpc_port", - nargs="?", - default=12345, - type=int, - help="EnSight gRPC port number", - ) - parser.add_argument( - "--host", - metavar="ensight_grpc_host", - nargs="?", - default="127.0.0.1", - type=str, - help="EnSight gRPC hostname", - ) - parser.add_argument( - "--security", - metavar="ensight_grpc_security_code", - nargs="?", - default="", - type=str, - help="EnSight gRPC security code", - ) - parser.add_argument( - "--verbose", - metavar="verbose_level", - default=0, - type=int, - help="Enable debugging information", - ) - parser.add_argument( - "--animation", dest="animation", action="store_true", help="Save all timesteps (default)" - ) - parser.add_argument( - "--no-animation", - dest="animation", - action="store_false", - help="Save only the current timestep", - ) - parser.set_defaults(animation=False) - parser.add_argument( - "--log_file", - metavar="log_filename", - default="", - type=str, - help="Save program output to the named log file instead of stdout", - ) - parser.add_argument( - "--live", - dest="live", - action="store_true", - default=False, - help="Enable continuous operation", - ) - parser.add_argument( - "--normalize_geometry", - dest="normalize", - action="store_true", - default=False, - help="Spatially normalize incoming geometry", - ) - parser.add_argument( - "--vrmode", - dest="vrmode", - action="store_true", - default=False, - help="In this mode do not include a camera or the case level matrix. Geometry only.", - ) - args = parser.parse_args() - - log_args = dict(format="DSG/Omniverse: %(message)s", level=logging.INFO) - if args.log_file: - log_args["filename"] = args.log_file - logging.basicConfig(**log_args) # type: ignore - - destinationPath = args.path - loggingEnabled = args.verbose - - # Build the OmniVerse connection - target = OmniverseWrapper(path=destinationPath, verbose=loggingEnabled, live_edit=args.live) - # Print the username for the server - target.username() - - if loggingEnabled: - logging.info("Omniverse connection established.") - - # link it to a DSG session - update_handler = OmniverseUpdateHandler(target) - dsg_link = DSGSession( - port=args.port, - host=args.host, - vrmode=args.vrmode, - security_code=args.security, - verbose=loggingEnabled, - normalize_geometry=args.normalize, - handler=update_handler, - ) - - if loggingEnabled: - dsg_link.log(f"Making DSG connection to: {args.host}:{args.port}") - - # Start the DSG link - err = dsg_link.start() - if err < 0: - sys.exit(err) - - # Simple pull request - dsg_link.request_an_update(animation=args.animation) - # Handle the update block - dsg_link.handle_one_update() - - # Live operation - if args.live: - if loggingEnabled: - dsg_link.log("Waiting for remote push operations") - while not dsg_link.is_shutdown(): - dsg_link.handle_one_update() - - # Done... - if loggingEnabled: - dsg_link.log("Shutting down DSG connection") - dsg_link.end() - - target.shutdown() diff --git a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone.mdl b/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone.mdl deleted file mode 100644 index a4bbb5c2338..00000000000 --- a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone.mdl +++ /dev/null @@ -1,54 +0,0 @@ -mdl 1.4; - -import ::OmniPBR::OmniPBR; -import ::anno::author; -import ::anno::description; -import ::anno::display_name; -import ::anno::key_words; -import ::anno::version; -import ::tex::gamma_mode; -import ::state::normal; - -export material Fieldstone(*) -[[ - ::anno::display_name("Omni PBR "), - ::anno::description("Omni PBR, supports ORM textures"), - ::anno::version(1, 0, 0, ""), - ::anno::author("NVIDIA CORPORATION"), - ::anno::key_words(string[]("omni", "PBR", "omniverse", "generic")) -]] - = ::OmniPBR::OmniPBR( - diffuse_color_constant: color(0.200000003f, 0.200000003f, 0.200000003f), - diffuse_texture: texture_2d("./Fieldstone/Fieldstone_BaseColor.png" /* tag 2828, version 6332211 */, ::tex::gamma_srgb), - albedo_desaturation: 0.f, - albedo_add: 0.f, - albedo_brightness: 1.f, - diffuse_tint: color(1.f, 1.f, 1.f), - reflection_roughness_constant: 0.5f, - reflection_roughness_texture_influence: 1.f, - reflectionroughness_texture: texture_2d(), - metallic_constant: 0.f, - metallic_texture_influence: 1.f, - metallic_texture: texture_2d(), - specular_level: 0.5f, - enable_ORM_texture: true, - ORM_texture: texture_2d("./Fieldstone/Fieldstone_ORM.png" /* tag 2830, version 596885211 */, ::tex::gamma_linear), - ao_to_diffuse: 0.f, - ao_texture: texture_2d(), - enable_emission: false, - emissive_color: color(1.f, 0.100000001f, 0.100000001f), - emissive_mask_texture: texture_2d(), - emissive_intensity: 40.f, - bump_factor: 1.f, - normalmap_texture: texture_2d("./Fieldstone/Fieldstone_N.png" /* tag 2832, version 3494456508 */, ::tex::gamma_linear), - detail_bump_factor: 0.300000012f, - detail_normalmap_texture: texture_2d(), - project_uvw: false, - world_or_object: false, - uv_space_index: 0, - texture_translate: float2(0.f), - texture_rotate: 0.f, - texture_scale: float2(1.f), - detail_texture_translate: float2(0.f), - detail_texture_rotate: 0.f, - detail_texture_scale: float2(1.f)); diff --git a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_BaseColor.png b/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_BaseColor.png deleted file mode 100644 index 29e83388335..00000000000 Binary files a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_BaseColor.png and /dev/null differ diff --git a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_N.png b/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_N.png deleted file mode 100644 index 2f08380c44a..00000000000 Binary files a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_N.png and /dev/null differ diff --git a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_ORM.png b/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_ORM.png deleted file mode 100644 index fd73d51f50e..00000000000 Binary files a/src/ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_ORM.png and /dev/null differ