ansys-pyensight-core 0.8.8__py3-none-any.whl → 0.8.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ansys-pyensight-core might be problematic. Click here for more details.

Files changed (41) hide show
  1. ansys/pyensight/core/__init__.py +1 -1
  2. ansys/pyensight/core/common.py +216 -0
  3. ansys/pyensight/core/dockerlauncher.py +85 -94
  4. ansys/pyensight/core/enshell_grpc.py +32 -0
  5. ansys/pyensight/core/launch_ensight.py +120 -19
  6. ansys/pyensight/core/launcher.py +10 -61
  7. ansys/pyensight/core/libuserd.py +1832 -0
  8. ansys/pyensight/core/locallauncher.py +47 -2
  9. ansys/pyensight/core/renderable.py +30 -0
  10. ansys/pyensight/core/session.py +6 -1
  11. ansys/pyensight/core/utils/dsg_server.py +227 -35
  12. ansys/pyensight/core/utils/omniverse.py +84 -24
  13. ansys/pyensight/core/utils/omniverse_cli.py +481 -0
  14. ansys/pyensight/core/utils/omniverse_dsg_server.py +236 -426
  15. ansys/pyensight/core/utils/omniverse_glb_server.py +279 -0
  16. ansys/pyensight/core/utils/readers.py +15 -11
  17. {ansys_pyensight_core-0.8.8.dist-info → ansys_pyensight_core-0.8.10.dist-info}/METADATA +10 -6
  18. ansys_pyensight_core-0.8.10.dist-info/RECORD +36 -0
  19. ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/__init__.py +0 -1
  20. ansys/pyensight/core/exts/ansys.geometry.service/ansys/geometry/service/extension.py +0 -407
  21. ansys/pyensight/core/exts/ansys.geometry.service/config/extension.toml +0 -59
  22. ansys/pyensight/core/exts/ansys.geometry.service/data/icon.png +0 -0
  23. ansys/pyensight/core/exts/ansys.geometry.service/data/preview.png +0 -0
  24. ansys/pyensight/core/exts/ansys.geometry.service/docs/CHANGELOG.md +0 -11
  25. ansys/pyensight/core/exts/ansys.geometry.service/docs/README.md +0 -13
  26. ansys/pyensight/core/exts/ansys.geometry.service/docs/index.rst +0 -18
  27. ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/__init__.py +0 -1
  28. ansys/pyensight/core/exts/ansys.geometry.serviceui/ansys/geometry/serviceui/extension.py +0 -193
  29. ansys/pyensight/core/exts/ansys.geometry.serviceui/config/extension.toml +0 -49
  30. ansys/pyensight/core/exts/ansys.geometry.serviceui/data/icon.png +0 -0
  31. ansys/pyensight/core/exts/ansys.geometry.serviceui/data/preview.png +0 -0
  32. ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/CHANGELOG.md +0 -11
  33. ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/README.md +0 -13
  34. ansys/pyensight/core/exts/ansys.geometry.serviceui/docs/index.rst +0 -18
  35. ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_BaseColor.png +0 -0
  36. ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_N.png +0 -0
  37. ansys/pyensight/core/utils/resources/Materials/Fieldstone/Fieldstone_ORM.png +0 -0
  38. ansys/pyensight/core/utils/resources/Materials/Fieldstone.mdl +0 -54
  39. ansys_pyensight_core-0.8.8.dist-info/RECORD +0 -52
  40. {ansys_pyensight_core-0.8.8.dist-info → ansys_pyensight_core-0.8.10.dist-info}/LICENSE +0 -0
  41. {ansys_pyensight_core-0.8.8.dist-info → ansys_pyensight_core-0.8.10.dist-info}/WHEEL +0 -0
@@ -12,6 +12,7 @@ import glob
12
12
  import logging
13
13
  import os.path
14
14
  import platform
15
+ import re
15
16
  import shutil
16
17
  import subprocess
17
18
  import tempfile
@@ -20,6 +21,7 @@ from typing import Optional
20
21
  import uuid
21
22
 
22
23
  import ansys.pyensight.core as pyensight
24
+ from ansys.pyensight.core.common import find_unused_ports
23
25
  from ansys.pyensight.core.launcher import Launcher
24
26
  import ansys.pyensight.core.session
25
27
 
@@ -56,6 +58,8 @@ class LocalLauncher(Launcher):
56
58
  Number of EnSight servers to use for SOS (Server of Server) mode.
57
59
  This parameter is defined on the parent ``Launcher`` class, where
58
60
  the default is ``None``, in which case SOS mode is not used.
61
+ additional_command_line_options: list, optional
62
+ Additional command line options to be used to launch EnSight.
59
63
 
60
64
  Examples
61
65
  --------
@@ -87,6 +91,7 @@ class LocalLauncher(Launcher):
87
91
  # launched process ids
88
92
  self._ensight_pid = None
89
93
  self._websocketserver_pid = None
94
+ self._webui_pid = None
90
95
  # and ports
91
96
  self._ports = None
92
97
  # Are we running the instance in batch
@@ -97,6 +102,36 @@ class LocalLauncher(Launcher):
97
102
  """Type of app to launch. Options are ``ensight`` and ``envision``."""
98
103
  return self._application
99
104
 
105
+ def launch_webui(self, cpython, webui_script, popen_common):
106
+ cmd = [cpython]
107
+ cmd += [webui_script]
108
+ version = re.findall(r"nexus(\d+)", webui_script)[0]
109
+ path_to_webui = self._install_path
110
+ path_to_webui = os.path.join(
111
+ path_to_webui, f"nexus{version}", f"ansys{version}", "ensight", "WebUI", "web", "ui"
112
+ )
113
+ cmd += ["--server-listen-port", str(self._ports[5])]
114
+ cmd += ["--server-web-roots", path_to_webui]
115
+ cmd += ["--ensight-grpc-port", str(self._ports[0])]
116
+ cmd += ["--ensight-html-port", str(self._ports[2])]
117
+ cmd += ["--ensight-ws-port", str(self._ports[3])]
118
+ cmd += ["--ensight-session-directory", self._session_directory]
119
+ cmd += ["--ensight-secret-key", self._secret_key]
120
+ if "PYENSIGHT_DEBUG" in os.environ:
121
+ try:
122
+ if int(os.environ["PYENSIGHT_DEBUG"]) > 0:
123
+ del popen_common["stdout"]
124
+ del popen_common["stderr"]
125
+ except (ValueError, KeyError):
126
+ pass
127
+ popen_common["env"].update(
128
+ {
129
+ "SIMBA_WEBSERVER_TOKEN": self._secret_key,
130
+ "FLUENT_WEBSERVER_TOKEN": self._secret_key,
131
+ }
132
+ )
133
+ self._webui_pid = subprocess.Popen(cmd, **popen_common).pid
134
+
100
135
  def start(self) -> "pyensight.Session":
101
136
  """Start an EnSight session using the local EnSight installation.
102
137
 
@@ -124,7 +159,10 @@ class LocalLauncher(Launcher):
124
159
 
125
160
  # gRPC port, VNC port, websocketserver ws, websocketserver html
126
161
  to_avoid = self._find_ports_used_by_other_pyensight_and_ensight()
127
- self._ports = self._find_unused_ports(5, avoid=to_avoid)
162
+ num_ports = 5
163
+ if self._launch_webui:
164
+ num_ports = 6
165
+ self._ports = find_unused_ports(num_ports, avoid=to_avoid)
128
166
  if self._ports is None:
129
167
  raise RuntimeError("Unable to allocate local ports for EnSight session")
130
168
  is_windows = self._is_windows()
@@ -154,6 +192,8 @@ class LocalLauncher(Launcher):
154
192
  vnc_url = f"vnc://%%3Frfb_port={self._ports[1]}%%26use_auth=0"
155
193
  cmd.extend(["-vnc", vnc_url])
156
194
  cmd.extend(["-ports", str(self._ports[4])])
195
+ if self._additional_command_line_options:
196
+ cmd.extend(self._additional_command_line_options)
157
197
 
158
198
  use_egl = self._use_egl()
159
199
 
@@ -207,11 +247,12 @@ class LocalLauncher(Launcher):
207
247
  except Exception:
208
248
  pass
209
249
  websocket_script = found_scripts[idx]
210
-
250
+ webui_script = websocket_script.replace("websocketserver.py", "webui_launcher.py")
211
251
  # build the commandline
212
252
  cmd = [os.path.join(self._install_path, "bin", "cpython"), websocket_script]
213
253
  if is_windows:
214
254
  cmd[0] += ".bat"
255
+ ensight_python = cmd[0]
215
256
  cmd.extend(["--http_directory", self.session_directory])
216
257
  # http port
217
258
  cmd.extend(["--http_port", str(self._ports[2])])
@@ -252,9 +293,13 @@ class LocalLauncher(Launcher):
252
293
  timeout=self._timeout,
253
294
  sos=use_sos,
254
295
  rest_api=self._enable_rest_api,
296
+ webui_port=self._ports[5] if self._launch_webui else None,
255
297
  )
256
298
  session.launcher = self
257
299
  self._sessions.append(session)
300
+
301
+ if self._launch_webui:
302
+ self.launch_webui(ensight_python, webui_script, popen_common)
258
303
  return session
259
304
 
260
305
  def stop(self) -> None:
@@ -3,10 +3,12 @@
3
3
  This module provides the interface for creating objects in the EnSight session
4
4
  that can be displayed via HTML over the websocket server interface.
5
5
  """
6
+ import hashlib
6
7
  import os
7
8
  import shutil
8
9
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, no_type_check
9
10
  import uuid
11
+ import warnings
10
12
  import webbrowser
11
13
 
12
14
  import requests
@@ -812,3 +814,31 @@ class RenderableSGEO(Renderable): # pragma: no cover
812
814
  html = html.replace("REVURL_ITEMID", revision_uri)
813
815
  html = html.replace("ITEMID", self._guid)
814
816
  return html
817
+
818
+
819
+ class RenderableFluidsWebUI(Renderable):
820
+ def __init__(self, *args, **kwargs) -> None:
821
+ super().__init__(*args, **kwargs)
822
+ self._session.ensight_version_check("2025 R1")
823
+ warnings.warn("The webUI is still under active development and should be considered beta.")
824
+ self._rendertype = "webui"
825
+ self._generate_url()
826
+ self.update()
827
+
828
+ def _generate_url(self) -> None:
829
+ sha256_hash = hashlib.sha256()
830
+ sha256_hash.update(self._session._secret_key.encode())
831
+ token = sha256_hash.hexdigest()
832
+ optional_query = self._get_query_parameters_str()
833
+ port = self._session._webui_port
834
+ if "instance_name" in self._session._launcher._get_query_parameters():
835
+ instance_name = self._session._launcher._get_query_parameters()["instance_name"]
836
+ # If using PIM, the port needs to be the 443 HTTPS Port;
837
+ port = self._session.html_port
838
+ # In the webUI code there's already a workflow to pass down the query parameter
839
+ # ans_instance_id, just use it
840
+ instance_name = self._session._launcher._get_query_parameters()["instance_name"]
841
+ optional_query = f"?ans_instance_id={instance_name}"
842
+ url = f"{self._http_protocol}://{self._session.html_hostname}:{port}"
843
+ url += f"{optional_query}#{token}"
844
+ self._url = url
@@ -31,6 +31,7 @@ from ansys.pyensight.core.listobj import ensobjlist
31
31
  from ansys.pyensight.core.renderable import (
32
32
  RenderableDeepPixel,
33
33
  RenderableEVSN,
34
+ RenderableFluidsWebUI,
34
35
  RenderableImage,
35
36
  RenderableMP4,
36
37
  RenderableSGEO,
@@ -136,6 +137,7 @@ class Session:
136
137
  timeout: float = 120.0,
137
138
  rest_api: bool = False,
138
139
  sos: bool = False,
140
+ webui_port: Optional[int] = None,
139
141
  ) -> None:
140
142
  # every session instance needs a unique name that can be used as a cache key
141
143
  self._session_name = str(uuid.uuid1())
@@ -161,6 +163,7 @@ class Session:
161
163
  self._grpc_port = grpc_port
162
164
  self._halt_ensight_on_close = True
163
165
  self._callbacks: Dict[str, Tuple[int, Any]] = dict()
166
+ self._webui_port = webui_port
164
167
  # if the caller passed a session directory we will assume they are
165
168
  # creating effectively a proxy Session and create a (stub) launcher
166
169
  if session_directory is not None:
@@ -927,6 +930,8 @@ class Session:
927
930
  # Undocumented. Available only internally
928
931
  elif what == "webensight":
929
932
  render = RenderableVNCAngular(self, **kwargs)
933
+ elif what == "webui":
934
+ render = RenderableFluidsWebUI(self, **kwargs)
930
935
 
931
936
  if render is None:
932
937
  raise RuntimeError("Unable to generate requested visualization")
@@ -1064,7 +1069,7 @@ class Session:
1064
1069
  onlyfiles = [f for f in listdir(_utils_dir) if os.path.isfile(os.path.join(_utils_dir, f))]
1065
1070
  for _basename in onlyfiles:
1066
1071
  # skip over any files with the "_server" in their names
1067
- if "_server" in _basename:
1072
+ if "_server" in _basename or "_cli" in _basename:
1068
1073
  continue
1069
1074
  _filename = os.path.join(_utils_dir, _basename)
1070
1075
  try:
@@ -1,8 +1,11 @@
1
+ import hashlib
2
+ import json
1
3
  import logging
2
4
  import os
3
5
  import queue
4
6
  import sys
5
7
  import threading
8
+ import time
6
9
  from typing import Any, Dict, List, Optional
7
10
 
8
11
  from ansys.api.pyensight.v0 import dynamic_scene_graph_pb2
@@ -32,9 +35,10 @@ class Part(object):
32
35
  self.normals = numpy.array([], dtype="float32")
33
36
  self.normals_elem = False
34
37
  self.tcoords = numpy.array([], dtype="float32")
35
- self.tcoords_var_id: Optional[int] = None
36
38
  self.tcoords_elem = False
39
+ self.node_sizes = numpy.array([], dtype="float32")
37
40
  self.cmd: Optional[Any] = None
41
+ self.hash = hashlib.new("sha256")
38
42
  self.reset()
39
43
 
40
44
  def reset(self, cmd: Any = None) -> None:
@@ -46,6 +50,10 @@ class Part(object):
46
50
  self.tcoords = numpy.array([], dtype="float32")
47
51
  self.tcoords_var_id = None
48
52
  self.tcoords_elem = False
53
+ self.node_sizes = numpy.array([], dtype="float32")
54
+ self.hash = hashlib.new("sha256")
55
+ if cmd is not None:
56
+ self.hash.update(cmd.hash.encode("utf-8"))
49
57
  self.cmd = cmd
50
58
 
51
59
  def update_geom(self, cmd: dynamic_scene_graph_pb2.UpdateGeom) -> None:
@@ -83,17 +91,25 @@ class Part(object):
83
91
  ):
84
92
  # Get the variable definition
85
93
  if cmd.variable_id in self.session.variables:
86
- self.tcoords_var_id = cmd.variable_id
87
- self.tcoords_elem = (
88
- cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE
89
- )
90
- if self.tcoords.size != cmd.total_array_size:
91
- self.tcoords = numpy.resize(self.tcoords, cmd.total_array_size)
92
- self.tcoords[
93
- cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
94
- ] = cmd.flt_array
95
- else:
96
- self.tcoords_var_id = None
94
+ if self.cmd.color_variableid == cmd.variable_id: # type: ignore
95
+ # Receive the colorby var values
96
+ self.tcoords_elem = (
97
+ cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE
98
+ )
99
+ if self.tcoords.size != cmd.total_array_size:
100
+ self.tcoords = numpy.resize(self.tcoords, cmd.total_array_size)
101
+ self.tcoords[
102
+ cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
103
+ ] = cmd.flt_array
104
+ if self.cmd.node_size_variableid == cmd.variable_id: # type: ignore
105
+ # Receive the node size var values
106
+ if self.node_sizes.size != cmd.total_array_size:
107
+ self.node_sizes = numpy.resize(self.node_sizes, cmd.total_array_size)
108
+ self.node_sizes[
109
+ cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
110
+ ] = cmd.flt_array
111
+ # Combine the hashes for the UpdatePart and all UpdateGeom messages
112
+ self.hash.update(cmd.hash.encode("utf-8"))
97
113
 
98
114
  def nodal_surface_rep(self):
99
115
  """
@@ -120,26 +136,7 @@ class Part(object):
120
136
  self.session.log(f"Note: part '{self.cmd.name}' contains no triangles.")
121
137
  return None, None, None, None, None, None
122
138
  verts = self.coords
123
- if self.session.normalize_geometry and self.session.scene_bounds is not None:
124
- midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5
125
- midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5
126
- midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5
127
- dx = self.session.scene_bounds[3] - self.session.scene_bounds[0]
128
- dy = self.session.scene_bounds[4] - self.session.scene_bounds[1]
129
- dz = self.session.scene_bounds[5] - self.session.scene_bounds[2]
130
- s = dx
131
- if dy > s:
132
- s = dy
133
- if dz > s:
134
- s = dz
135
- if s == 0:
136
- s = 1.0
137
- num_verts = int(verts.size / 3)
138
- for i in range(num_verts):
139
- j = i * 3
140
- verts[j + 0] = (verts[j + 0] - midx) / s
141
- verts[j + 1] = (verts[j + 1] - midy) / s
142
- verts[j + 2] = (verts[j + 2] - midz) / s
139
+ self.normalize_verts(verts)
143
140
 
144
141
  conn = self.conn_tris
145
142
  normals = self.normals
@@ -242,6 +239,141 @@ class Part(object):
242
239
 
243
240
  return command, verts, conn, normals, tcoords, var_cmd
244
241
 
242
+ def normalize_verts(self, verts: numpy.ndarray):
243
+ """
244
+ This function scales and translates vertices, so the longest axis in the scene is of
245
+ length 1.0, and data is centered at the origin
246
+
247
+ Returns the scale factor
248
+ """
249
+ s = 1.0
250
+ if self.session.normalize_geometry and self.session.scene_bounds is not None:
251
+ num_verts = int(verts.size / 3)
252
+ midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5
253
+ midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5
254
+ midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5
255
+ dx = self.session.scene_bounds[3] - self.session.scene_bounds[0]
256
+ dy = self.session.scene_bounds[4] - self.session.scene_bounds[1]
257
+ dz = self.session.scene_bounds[5] - self.session.scene_bounds[2]
258
+ s = dx
259
+ if dy > s:
260
+ s = dy
261
+ if dz > s:
262
+ s = dz
263
+ if s == 0:
264
+ s = 1.0
265
+ for i in range(num_verts):
266
+ j = i * 3
267
+ verts[j + 0] = (verts[j + 0] - midx) / s
268
+ verts[j + 1] = (verts[j + 1] - midy) / s
269
+ verts[j + 2] = (verts[j + 2] - midz) / s
270
+ return 1.0 / s
271
+
272
+ def point_rep(self):
273
+ """
274
+ This function processes the geometry arrays and returns values to represent point data
275
+
276
+ Returns
277
+ -------
278
+ On failure, the method returns None for the first return value. The returned tuple is:
279
+
280
+ (part_command, vertices, sizes, colors, var_command)
281
+
282
+ part_command: UPDATE_PART command object
283
+ vertices: numpy array of per-node coordinates
284
+ sizes: numpy array of per-node radii
285
+ colors: numpy array of per-node rgb colors
286
+ var_command: UPDATE_VARIABLE command object for the variable the colors correspond to, if any
287
+ """
288
+ if self.cmd is None:
289
+ return None, None, None, None, None
290
+ if self.cmd.render != self.cmd.NODES:
291
+ # Early out. Rendering type for this object is a surface rep, not a point rep
292
+ return None, None, None, None, None
293
+ verts = self.coords
294
+ num_verts = int(verts.size / 3)
295
+ norm_scale = self.normalize_verts(verts)
296
+
297
+ # Convert var values in self.tcoords to RGB colors
298
+ # For now, look up RGB colors. Planned USD enhancements should allow tex coords instead.
299
+ colors = None
300
+ var_cmd = None
301
+
302
+ if self.tcoords.size and self.tcoords.size == num_verts:
303
+ var_dsg_id = self.cmd.color_variableid
304
+ var_cmd = self.session.variables[var_dsg_id]
305
+ if len(var_cmd.levels) == 0:
306
+ self.session.log(
307
+ f"Note: Node rep not created for part '{self.cmd.name}'. It has var values, but a palette with 0 levels."
308
+ )
309
+ return None, None, None, None, None
310
+
311
+ p_min = None
312
+ p_max = None
313
+ for lvl in var_cmd.levels:
314
+ if (p_min is None) or (p_min > lvl.value):
315
+ p_min = lvl.value
316
+ if (p_max is None) or (p_max < lvl.value):
317
+ p_max = lvl.value
318
+
319
+ num_texels = int(len(var_cmd.texture) / 4)
320
+
321
+ colors = numpy.ndarray((num_verts * 3,), dtype="float32")
322
+ low_color = [c / 255.0 for c in var_cmd.texture[0:3]]
323
+ high_color = [
324
+ c / 255.0 for c in var_cmd.texture[4 * (num_texels - 1) : 4 * (num_texels - 1) + 3]
325
+ ]
326
+ if p_min == p_max:
327
+ # Special case where palette min == palette max
328
+ mid_color = var_cmd[4 * (num_texels // 2) : 4 * (num_texels // 2) + 3]
329
+ for idx in range(num_verts):
330
+ val = self.tcoords[idx]
331
+ if val == p_min:
332
+ colors[idx * 3 : idx * 3 + 3] = mid_color
333
+ elif val < p_min:
334
+ colors[idx * 3 : idx * 3 + 3] = low_color
335
+ elif val > p_min:
336
+ colors[idx * 3 : idx * 3 + 3] = high_color
337
+ else:
338
+ for idx in range(num_verts):
339
+ val = self.tcoords[idx]
340
+ if val <= p_min:
341
+ colors[idx * 3 : idx * 3 + 3] = low_color
342
+ else:
343
+ pal_pos = (num_texels - 1) * (val - p_min) / (p_max - p_min)
344
+ pal_idx, pal_sub = divmod(pal_pos, 1)
345
+ pal_idx = int(pal_idx)
346
+
347
+ if pal_idx >= num_texels - 1:
348
+ colors[idx * 3 : idx * 3 + 3] = high_color
349
+ else:
350
+ col0 = var_cmd.texture[pal_idx * 4 : pal_idx * 4 + 3]
351
+ col1 = var_cmd.texture[4 + pal_idx * 4 : 4 + pal_idx * 4 + 3]
352
+ for ii in range(0, 3):
353
+ colors[idx * 3 + ii] = (
354
+ col0[ii] * pal_sub + col1[ii] * (1.0 - pal_sub)
355
+ ) / 255.0
356
+ self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.")
357
+
358
+ node_sizes = None
359
+ if self.node_sizes.size and self.node_sizes.size == num_verts:
360
+ # Pass out the node sizes if there is a size-by variable
361
+ node_size_default = self.cmd.node_size_default * norm_scale
362
+ node_sizes = numpy.ndarray((num_verts,), dtype="float32")
363
+ for ii in range(0, num_verts):
364
+ node_sizes[ii] = self.node_sizes[ii] * node_size_default
365
+ elif norm_scale != 1.0:
366
+ # Pass out the node sizes if the model is normalized to fit in a unit cube
367
+ node_size_default = self.cmd.node_size_default * norm_scale
368
+ node_sizes = numpy.ndarray((num_verts,), dtype="float32")
369
+ for ii in range(0, num_verts):
370
+ node_sizes[ii] = node_size_default
371
+
372
+ self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.")
373
+ command = self.cmd
374
+
375
+ return command, verts, node_sizes, colors, var_cmd
376
+
245
377
 
246
378
  class UpdateHandler(object):
247
379
  """
@@ -410,6 +542,9 @@ class DSGSession(object):
410
542
  self._scene_bounds: Optional[List] = None
411
543
  self._cur_timeline: List = [0.0, 0.0] # Start/End time for current update
412
544
  self._callback_handler.session = self
545
+ # log any status changes to this file. external apps will be monitoring
546
+ self._status_file = os.environ.get("ANSYS_OV_SERVER_STATUS_FILENAME", "")
547
+ self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0)
413
548
 
414
549
  @property
415
550
  def scene_bounds(self) -> Optional[List]:
@@ -474,6 +609,15 @@ class DSGSession(object):
474
609
  if level < self._verbose:
475
610
  logging.info(s)
476
611
 
612
+ @staticmethod
613
+ def warn(s: str) -> None:
614
+ """Issue a warning to the logging system
615
+
616
+ The logging message is mapped to "warn" and cannot be blocked via verbosity
617
+ checks.
618
+ """
619
+ logging.warning(s)
620
+
477
621
  def start(self) -> int:
478
622
  """Start a gRPC connection to an EnSight instance
479
623
 
@@ -518,6 +662,38 @@ class DSGSession(object):
518
662
  """Check the service shutdown request status"""
519
663
  return self._shutdown
520
664
 
665
+ def _update_status_file(self, timed: bool = False):
666
+ """
667
+ Update the status file contents. The status file will contain the
668
+ following json object, stored as: self._status
669
+
670
+ {
671
+ 'status' : "working|idle",
672
+ 'start_time' : timestamp_of_update_begin,
673
+ 'processed_buffers' : number_of_protobuffers_processed,
674
+ 'total_buffers' : number_of_protobuffers_total,
675
+ }
676
+
677
+ Parameters
678
+ ----------
679
+ timed : bool, optional:
680
+ if True, only update every second.
681
+
682
+ """
683
+ if self._status_file:
684
+ current_time = time.time()
685
+ if timed:
686
+ last_time = self._status.get("last_time", 0.0)
687
+ if current_time - last_time < 1.0: # type: ignore
688
+ return
689
+ self._status["last_time"] = current_time
690
+ try:
691
+ message = json.dumps(self._status)
692
+ with open(self._status_file, "w") as status_file:
693
+ status_file.write(message)
694
+ except IOError:
695
+ pass # Note failure is expected here in some cases
696
+
521
697
  def request_an_update(self, animation: bool = False, allow_spontaneous: bool = True) -> None:
522
698
  """Start a DSG update
523
699
  Send a command to the DSG protocol to "init" an update.
@@ -590,12 +766,21 @@ class DSGSession(object):
590
766
  self._mesh_block_count = 0 # reset when a new group shows up
591
767
  self._callback_handler.begin_update()
592
768
 
769
+ # Update our status
770
+ self._status = dict(
771
+ status="working", start_time=time.time(), processed_buffers=1, total_buffers=1
772
+ )
773
+ self._update_status_file()
774
+
593
775
  # handle the various commands until UPDATE_SCENE_END
594
776
  cmd = self._get_next_message()
595
777
  while (cmd is not None) and (
596
778
  cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_END
597
779
  ):
598
780
  self._handle_update_command(cmd)
781
+ self._status["processed_buffers"] += 1 # type: ignore
782
+ self._status["total_buffers"] = self._status["processed_buffers"] + self._message_queue.qsize() # type: ignore
783
+ self._update_status_file(timed=True)
599
784
  cmd = self._get_next_message()
600
785
 
601
786
  # Flush the last part
@@ -603,6 +788,10 @@ class DSGSession(object):
603
788
 
604
789
  self._callback_handler.end_update()
605
790
 
791
+ # Update our status
792
+ self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0)
793
+ self._update_status_file()
794
+
606
795
  def _handle_update_command(self, cmd: dynamic_scene_graph_pb2.SceneUpdateCommand) -> None:
607
796
  """Dispatch out a scene update command to the proper handler
608
797
 
@@ -647,10 +836,13 @@ class DSGSession(object):
647
836
  There is always a part being modified. This method completes the current part, committing
648
837
  it to the handler.
649
838
  """
650
- self._callback_handler.finalize_part(self.part)
839
+ try:
840
+ self._callback_handler.finalize_part(self.part)
841
+ except Exception as e:
842
+ self.warn(f"Error encountered while finalizing part geometry: {str(e)}")
651
843
  self._mesh_block_count += 1
652
844
 
653
- def _handle_part(self, part: Any) -> None:
845
+ def _handle_part(self, part_cmd: Any) -> None:
654
846
  """Handle a DSG UPDATE_PART command
655
847
 
656
848
  Finish the current part and set up the next part.
@@ -661,7 +853,7 @@ class DSGSession(object):
661
853
  The command coming from the EnSight stream.
662
854
  """
663
855
  self._finish_part()
664
- self._part.reset(part)
856
+ self._part.reset(part_cmd)
665
857
 
666
858
  def _handle_group(self, group: Any) -> None:
667
859
  """Handle a DSG UPDATE_GROUP command