ansys-pyensight-core 0.9.1__py3-none-any.whl → 0.9.2__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.

@@ -4,6 +4,11 @@ This package defines the EnSightGRPC class which provides a simpler
4
4
  interface to the EnSight gRPC interface, including event streams.
5
5
 
6
6
  """
7
+ from concurrent import futures
8
+ import os
9
+ import platform
10
+ import sys
11
+ import tempfile
7
12
  import threading
8
13
  from typing import Any, Callable, List, Optional, Tuple, Union
9
14
  import uuid
@@ -47,6 +52,14 @@ class EnSightGRPC(object):
47
52
  # Callback for events (self._events not used)
48
53
  self._event_callback: Optional[Callable] = None
49
54
  self._prefix: Optional[str] = None
55
+ self._shmem_module = None
56
+ self._shmem_filename: Optional[str] = None
57
+ self._shmem_client = None
58
+ self._image_stream = None
59
+ self._image_thread = None
60
+ self._image = None
61
+ self._image_number = 0
62
+ self._sub_service = None
50
63
 
51
64
  @property
52
65
  def host(self) -> str:
@@ -120,6 +133,12 @@ class EnSightGRPC(object):
120
133
  if self._channel:
121
134
  self._channel.close()
122
135
  self._channel = None
136
+ if self._shmem_client:
137
+ if self._shmem_module:
138
+ self._shmem_module.stream_destroy(self._shmem_client)
139
+ else:
140
+ self.command("ensight_grpc_shmem.stream_destroy(enscl._shmem_client)")
141
+ self._shmem_client = None
123
142
 
124
143
  def is_connected(self) -> bool:
125
144
  """Check to see if the gRPC connection is live
@@ -430,3 +449,286 @@ class EnSightGRPC(object):
430
449
  # signal that the gRPC connection has broken
431
450
  self._event_stream = None
432
451
  self._event_thread = None
452
+
453
+ def _attempt_shared_mem_import(self):
454
+ try:
455
+ import ensight_grpc_shmem
456
+
457
+ self._shmem_module = ensight_grpc_shmem
458
+ except ModuleNotFoundError:
459
+ try:
460
+ self.command("import enve", do_eval=False)
461
+ cei_home = eval(self.command("enve.home()"))
462
+ self.command("import ceiversion", do_eval=False)
463
+ cei_version = eval(self.command("ceiversion.version_suffix"))
464
+ self.command("import sys", do_eval=False)
465
+ py_version = eval(self.command("sys.version_info[:3]"))
466
+ is_win = True if "Win" in platform.system() else False
467
+ plat = "win64" if is_win else "linux_2.6_64"
468
+ _lib = "DLLs" if is_win else f"lib/python{py_version[0]}.{py_version[1]}"
469
+ dll_loc = os.path.join(
470
+ cei_home,
471
+ f"apex{cei_version}",
472
+ "machines",
473
+ plat,
474
+ f"Python-{py_version[0]}.{py_version[1]}.{py_version[2]}",
475
+ _lib,
476
+ )
477
+ if os.path.exists(dll_loc):
478
+ sys.path.append(dll_loc)
479
+ import ensight_grpc_shmem
480
+
481
+ self._shmem_module = ensight_grpc_shmem
482
+ except ModuleNotFoundError:
483
+ pass
484
+
485
+ @classmethod
486
+ def _find_filename(cls, size=1024 * 1024 * 25):
487
+ """Create a file on disk to support shared memory transport.
488
+
489
+ A file, 25MB in size, will be created using the pid of the current
490
+ process to generate the filename. It will be located in a temporary
491
+ directory.
492
+ """
493
+ tempdir = tempfile.mkdtemp(prefix="pyensight_shmem")
494
+ for i in range(100):
495
+ filename = os.path.join(tempdir, "shmem_{}.bin".format(os.getpid() + i))
496
+ if not os.path.exists(filename):
497
+ try:
498
+ tmp = open(filename, "wb")
499
+ tmp.write(b"\0" * size) # 25MB
500
+ tmp.close()
501
+ return filename
502
+ except Exception:
503
+ pass
504
+ return None
505
+
506
+ def get_image(self):
507
+ """Retrieve the current EnSight image.
508
+
509
+ When any of the image streaming systems is enabled, Python threads will receive the
510
+ most recent image and store them in this instance. The frame stored in this instance
511
+ can be accessed by calling this method
512
+
513
+ Returns
514
+ -------
515
+ (tuple):
516
+ A tuple containing a dictionary defining the image binary
517
+ (pixels=bytearray, width=w, height=h) and the image frame number.
518
+ """
519
+ return self._image, self._image_number
520
+
521
+ def _start_sub_service(self):
522
+ """Start a gRPC client service.
523
+ When the client calls one subscribe_events() or subscribe_images() with the
524
+ connection set to GRPC, the interface requires the client to start a gRPC server
525
+ that EnSight will call back to with event/image messages. This method starts
526
+ such a gRPC server."""
527
+ try:
528
+ if self._sub_service is not None:
529
+ return
530
+ self._sub_service = _EnSightSubServicer(parent=self)
531
+ self._sub_service.start()
532
+ except Exception:
533
+ self._sub_service = None
534
+
535
+ def subscribe_images(self, flip_vertical=False, use_shmem=True):
536
+ """Subscribe to an image stream.
537
+
538
+ This methond makes a EnSightService::SubscribeImages() gRPC call. If
539
+ use_shmem is False, the transport system will be made over gRPC. It causes
540
+ EnSight to make a reverse gRPC connection over with gRPC calls with the
541
+ various images will be made. If use_shmem is True (the default), the \ref shmem will be used.
542
+
543
+ Parameters
544
+ ---------
545
+ flip_vertical: bool
546
+ If True, the image pixels will be flipped over the X axis
547
+ use_shmem: bool
548
+ If True, use the shared memory transport, otherwise use reverse gRPC"""
549
+ self.connect()
550
+ if use_shmem:
551
+ try:
552
+ # we need a shared memory file
553
+ self._shmem_filename = self._find_filename()
554
+ if self._shmem_filename is not None:
555
+ conn_type = ensight_pb2.SubscribeImageOptions.SHARED_MEM
556
+ options = dict(filename=self._shmem_filename)
557
+ image_options = ensight_pb2.SubscribeImageOptions(
558
+ prefix=self.prefix(),
559
+ type=conn_type,
560
+ options=options,
561
+ flip_vertical=flip_vertical,
562
+ chunk=False,
563
+ )
564
+ _ = self._stub.SubscribeImages(image_options, metadata=self._metadata())
565
+ # start the local server
566
+ if not self._shmem_module:
567
+ self._attempt_shared_mem_import()
568
+ if self._shmem_module:
569
+ self._shmem_client = self._shmem_module.stream_create(self._shmem_filename)
570
+ else:
571
+ self.command("import ensight_grpc_shmem", do_eval=False)
572
+ to_send = self._shmem_filename.replace("\\", "\\\\")
573
+ self.command(
574
+ f"enscl._shmem_client = ensight_grpc_shmem.stream_create('{to_send}')",
575
+ do_eval=False,
576
+ )
577
+ if self.command("enscl._shmem_client is not None"):
578
+ self._shmem_client = True
579
+
580
+ # turn on the polling thread
581
+ self._image_thread = threading.Thread(target=self._poll_images)
582
+ self._image_thread.daemon = True
583
+ self._image_thread.start()
584
+ return
585
+ except Exception as e:
586
+ print("Unable to subscribe to an image stream via shared memory: {}".format(str(e)))
587
+
588
+ self._start_sub_service()
589
+ conn_type = ensight_pb2.SubscribeImageOptions.GRPC
590
+ options = {}
591
+ if self._sub_service:
592
+ options = dict(uri=self._sub_service._uri)
593
+ image_options = ensight_pb2.SubscribeImageOptions(
594
+ prefix=self.prefix(),
595
+ type=conn_type,
596
+ options=options,
597
+ flip_vertical=flip_vertical,
598
+ chunk=True,
599
+ )
600
+ _ = self._stub.SubscribeImages(image_options, metadata=self._metadata())
601
+
602
+ def image_stream_enable(self, flip_vertical=False):
603
+ """Enable a simple gRPC-based image stream from EnSight.
604
+
605
+ This method makes a EnSightService::GetImageStream() gRPC call into EnSight, returning
606
+ an ensightservice::ImageReply stream. The method creates a thread to hold this
607
+ stream open and read new image frames from it. The thread places the read images
608
+ in this object. An external application can retrieve the most recent one using
609
+ get_image().
610
+
611
+ Parameters
612
+ ----------
613
+ flip_vertical: bool
614
+ If True, the image will be flipped over the X axis before being sent from EnSight."""
615
+ if self._image_stream is not None:
616
+ return
617
+ self.connect()
618
+ self._image_stream = self._stub.GetImageStream(
619
+ ensight_pb2.ImageStreamRequest(flip_vertical=flip_vertical, chunk=True),
620
+ metadata=self._metadata(),
621
+ )
622
+ self._image_thread = threading.Thread(target=self._poll_images)
623
+ self._image_thread.daemon = True
624
+ self._image_thread.start()
625
+
626
+ def _put_image(self, the_image):
627
+ """Store an image on this instance.
628
+
629
+ This method is used by threads to store the latest image they receive
630
+ so it can be accessed by get_image.
631
+ """
632
+ self._image = the_image
633
+ self._image_number += 1
634
+
635
+ def image_stream_is_enabled(self):
636
+ """Check to see if the image stream is enabled.
637
+
638
+ If an image stream has been successfully established via image_stream_enable(),
639
+ then this function returns True.
640
+
641
+ Returns
642
+ -------
643
+ (bool):
644
+ True if a ensightservice::ImageReply steam is active
645
+ """
646
+ return self._image_stream is not None
647
+
648
+ def _poll_images(self):
649
+ """Handle image streams.
650
+
651
+ This method is called by a Python thread to read imagery via the shared memory
652
+ transport system or the the ensightservice::ImageReply stream.
653
+ """
654
+ try:
655
+ while self._stub is not None:
656
+ if self._shmem_client:
657
+ if self._shmem_module:
658
+ img = self._shmem_module.stream_lock(self._shmem_client)
659
+ else:
660
+ img = self.command("ensight_grpc_shmem.stream_lock(enscl._shmem_client)")
661
+ if type(img) is dict:
662
+ the_image = dict(
663
+ pixels=img["pixeldata"], width=img["width"], height=img["height"]
664
+ )
665
+ self._put_image(the_image)
666
+ if self._shmem_module:
667
+ self._shmem_module.stream_unlock(self._shmem_client)
668
+ else:
669
+ self.command(
670
+ "ensight_grpc_shmem.stream_unlock(enscl._shmem_client)",
671
+ do_eval=False,
672
+ )
673
+
674
+ if self._image_stream is not None:
675
+ img = self._image_stream.next()
676
+ buffer = img.pixels
677
+
678
+ while not img.final:
679
+ img = self._image_stream.next()
680
+ buffer += img.pixels
681
+
682
+ the_image = dict(pixels=buffer, width=img.width, height=img.height)
683
+ self._put_image(the_image)
684
+ except Exception:
685
+ # signal that the gRPC connection has broken
686
+ self._image_stream = None
687
+ self._image_thread = None
688
+ self._image = None
689
+
690
+
691
+ class _EnSightSubServicer(ensight_pb2_grpc.EnSightSubscriptionServicer):
692
+ """Internal class handling reverse subscription connections.
693
+ The EnSight gRPC interface has a mechanism for reversing the gRPC
694
+ streams called Subscriptions. Image and event streams can be
695
+ subscribed to. In this mode, the client application starts a
696
+ gRPC server that implements the EnSightSubscription protocol.
697
+ EnSight will connect back to the client using this protocol and
698
+ send images/events back to the client as regular (non-stream)
699
+ rpc calls. This can be useful in situations where it is difficult
700
+ keep a long-running stream alive.
701
+ The EnSightSubServicer class implements a gRPC server for the client application.
702
+ """
703
+
704
+ def __init__(self, parent: Optional["EnSightGRPC"] = None):
705
+ self._server: Optional["grpc.Server"] = None
706
+ self._uri: str = ""
707
+ self._parent = parent
708
+
709
+ def PublishEvent(self, request: Any, context: Any) -> "ensight_pb2.GenericResponse":
710
+ """Publish an event to the remote server."""
711
+ if self._parent is not None:
712
+ self._parent._put_event(request)
713
+ return ensight_pb2.GenericResponse(str="Event Published")
714
+
715
+ def PublishImage(self, request_iterator: Any, context: Any) -> "ensight_pb2.GenericResponse":
716
+ """Publish a single image (possibly in chucks) to the remote server."""
717
+ img: Any = request_iterator.next()
718
+ buffer = img.pixels
719
+ while not img.final:
720
+ img = request_iterator.next()
721
+ buffer += img.pixels
722
+ the_image = dict(pixels=buffer, width=img.width, height=img.height)
723
+ if self._parent is not None:
724
+ self._parent._put_image(the_image)
725
+ return ensight_pb2.GenericResponse(str="Image Published")
726
+
727
+ def start(self):
728
+ """Start the gRPC server to be used for the EnSight Subscription Service."""
729
+ self._server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
730
+ ensight_pb2_grpc.add_EnSightSubscriptionServicer_to_server(self, self._server)
731
+ # Start the server on localhost with a random port
732
+ port = self._server.add_insecure_port("localhost:0")
733
+ self._uri = "localhost:" + str(port)
734
+ self._server.start()
@@ -6,7 +6,7 @@ import subprocess
6
6
  import sys
7
7
  import tempfile
8
8
  from types import ModuleType
9
- from typing import TYPE_CHECKING, Optional, Union
9
+ from typing import TYPE_CHECKING, List, Optional, Union
10
10
  import uuid
11
11
 
12
12
  import psutil
@@ -18,6 +18,199 @@ if TYPE_CHECKING:
18
18
  from ansys.api.pyensight import ensight_api
19
19
 
20
20
 
21
+ class OmniverseKitInstance:
22
+ """Interface to an Omniverse application instance
23
+
24
+ Parameters
25
+ ----------
26
+ pid : int
27
+ The process id of the launched instance
28
+ """
29
+
30
+ def __init__(self, pid: int) -> None:
31
+ self._pid: Optional[int] = pid
32
+ print("CTOR:", pid)
33
+
34
+ def __del__(self) -> None:
35
+ """Close down the instance on delete"""
36
+ self.close()
37
+
38
+ def close(self) -> None:
39
+ """Shutdown the Omniverse instance
40
+
41
+ If the instance associated with this object is still running,
42
+ shut it down.
43
+ """
44
+ if not self.is_running():
45
+ return
46
+ proc = psutil.Process(self._pid)
47
+ for child in proc.children(recursive=True):
48
+ if psutil.pid_exists(child.pid):
49
+ # This can be a race condition, so it is ok if the child is dead already
50
+ try:
51
+ child.kill()
52
+ except psutil.NoSuchProcess:
53
+ pass
54
+ # Same issue, this process might already be shutting down, so NoSuchProcess is ok.
55
+ try:
56
+ proc.kill()
57
+ except psutil.NoSuchProcess:
58
+ pass
59
+ self._pid = None
60
+
61
+ def is_running(self) -> bool:
62
+ """Check if the instance is still running
63
+
64
+ Returns
65
+ -------
66
+ bool
67
+ True if the instance is still running.
68
+ """
69
+ if not self._pid:
70
+ return False
71
+ if psutil.pid_exists(self._pid):
72
+ return True
73
+ self._pid = None
74
+ return False
75
+
76
+
77
+ def find_kit_filename(fallback_directory: Optional[str] = None) -> Optional[str]:
78
+ """
79
+ Use a combination of the current omniverse application and the information
80
+ in the local .nvidia-omniverse/config/omniverse.toml file to come up with
81
+ the pathname of a kit executable suitable for hosting another copy of the
82
+ ansys.geometry.server kit.
83
+
84
+ Returns
85
+ -------
86
+ Optional[str]
87
+ The pathname of a kit executable or None
88
+
89
+ """
90
+ # parse the toml config file for the location of the installed apps
91
+ try:
92
+ import tomllib
93
+ except ModuleNotFoundError:
94
+ import pip._vendor.tomli as tomllib
95
+
96
+ homedir = os.path.expanduser("~")
97
+ ov_config = os.path.join(homedir, ".nvidia-omniverse", "config", "omniverse.toml")
98
+ if not os.path.exists(ov_config):
99
+ return None
100
+ # read the Omniverse configuration toml file
101
+ with open(ov_config, "r") as ov_file:
102
+ ov_data = ov_file.read()
103
+ config = tomllib.loads(ov_data)
104
+ appdir = config.get("paths", {}).get("library_root", fallback_directory)
105
+
106
+ # If we are running inside an Omniverse app, use that information
107
+ try:
108
+ import omni.kit.app
109
+
110
+ # get the current application
111
+ app = omni.kit.app.get_app()
112
+ app_name = app.get_app_filename().split(".")[-1]
113
+ app_version = app.get_app_version().split("-")[0]
114
+ # and where it is installed
115
+ appdir = os.path.join(appdir, f"{app_name}-{app_version}")
116
+ except ModuleNotFoundError:
117
+ # Names should be like: "C:\\Users\\foo\\AppData\\Local\\ov\\pkg\\create-2023.2.3\\launcher.toml"
118
+ target = None
119
+ target_version = None
120
+ for d in glob.glob(os.path.join(appdir, "*", "launcher.toml")):
121
+ test_dir = os.path.dirname(d)
122
+ # the name will be something like "create-2023.2.3"
123
+ name = os.path.basename(test_dir).split("-")
124
+ if len(name) != 2:
125
+ continue
126
+ if name[0] not in ("kit", "create", "view"):
127
+ continue
128
+ if (target_version is None) or (name[1] > target_version):
129
+ target = test_dir
130
+ target_version = name[1]
131
+ if target is None:
132
+ return None
133
+ appdir = target
134
+
135
+ # Windows: 'kit.bat' in '.' or 'kit' followed by 'kit.exe' in '.' or 'kit'
136
+ # Linux: 'kit.sh' in '.' or 'kit' followed by 'kit' in '.' or 'kit'
137
+ exe_names = ["kit.sh", "kit"]
138
+ if sys.platform.startswith("win"):
139
+ exe_names = ["kit.bat", "kit.exe"]
140
+
141
+ # look in 4 places...
142
+ for dir_name in [appdir, os.path.join(appdir, "kit")]:
143
+ for exe_name in exe_names:
144
+ if os.path.exists(os.path.join(dir_name, exe_name)):
145
+ return os.path.join(dir_name, exe_name)
146
+
147
+ return None
148
+
149
+
150
+ def launch_kit_instance(
151
+ kit_path: Optional[str] = None,
152
+ extension_paths: Optional[List[str]] = None,
153
+ extensions: Optional[List[str]] = None,
154
+ cli_options: Optional[List[str]] = None,
155
+ log_file: Optional[str] = None,
156
+ log_level: str = "warn",
157
+ ) -> "OmniverseKitInstance":
158
+ """Launch an Omniverse application instance
159
+
160
+ Parameters
161
+ ----------
162
+ kit_path : Optional[str]
163
+ The full pathname of to a binary capable of serving as a kit runner.
164
+ extension_paths : Optional[List[str]]
165
+ List of directory names to include the in search for kits.
166
+ extensions : Optional[List[str]]
167
+ List of kit extensions to be loaded into the launched kit instance.
168
+ log_file : Optional[str]
169
+ The name of a text file where the logging information for the instance will be saved.
170
+ log_level : str
171
+ The level of the logging information to record: "verbose", "info", "warn", "error", "fatal",
172
+ the default is "warn".
173
+
174
+ Returns
175
+ -------
176
+ OmniverseKitInstance
177
+ The object interface for the launched instance
178
+
179
+ Examples
180
+ --------
181
+ Run a simple, empty GUI kit instance.
182
+
183
+ >>> from ansys.pyensight.core.utils import omniverse
184
+ >>> ov = omniverse.launch_kit_instance(extensions=['omni.kit.uiapp'])
185
+
186
+ """
187
+ # build the command line
188
+ if not kit_path:
189
+ kit_path = find_kit_filename()
190
+ if not kit_path:
191
+ raise RuntimeError("Unable to find a suitable Omniverse kit install")
192
+ cmd = [kit_path]
193
+ if extension_paths:
194
+ for path in extension_paths:
195
+ cmd.extend(["--ext-folder", path])
196
+ if extensions:
197
+ for ext in extensions:
198
+ cmd.extend(["--enable", ext])
199
+ if cli_options:
200
+ for opt in cli_options:
201
+ cmd.append(opt)
202
+ if log_level not in ("verbose", "info", "warn", "error", "fatal"):
203
+ raise RuntimeError(f"Invalid logging level: {log_level}")
204
+ cmd.append(f"--/log/level={log_level}")
205
+ if log_file:
206
+ cmd.append(f"--/log/file={log_file}")
207
+ cmd.append("--/log/enabled=true")
208
+ # Launch the process
209
+ env_vars = os.environ.copy()
210
+ p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env_vars)
211
+ return OmniverseKitInstance(p.pid)
212
+
213
+
21
214
  class Omniverse:
22
215
  """Provides the ``ensight.utils.omniverse`` interface.
23
216
 
@@ -56,79 +249,6 @@ class Omniverse:
56
249
  self._interpreter: str = ""
57
250
  self._status_filename: str = ""
58
251
 
59
- @staticmethod
60
- def find_kit_filename(fallback_directory: Optional[str] = None) -> Optional[str]:
61
- """
62
- Use a combination of the current omniverse application and the information
63
- in the local .nvidia-omniverse/config/omniverse.toml file to come up with
64
- the pathname of a kit executable suitable for hosting another copy of the
65
- ansys.geometry.server kit.
66
-
67
- Returns
68
- -------
69
- Optional[str]
70
- The pathname of a kit executable or None
71
-
72
- """
73
- # parse the toml config file for the location of the installed apps
74
- try:
75
- import tomllib
76
- except ModuleNotFoundError:
77
- import pip._vendor.tomli as tomllib
78
-
79
- homedir = os.path.expanduser("~")
80
- ov_config = os.path.join(homedir, ".nvidia-omniverse", "config", "omniverse.toml")
81
- if not os.path.exists(ov_config):
82
- return None
83
- # read the Omniverse configuration toml file
84
- with open(ov_config, "r") as ov_file:
85
- ov_data = ov_file.read()
86
- config = tomllib.loads(ov_data)
87
- appdir = config.get("paths", {}).get("library_root", fallback_directory)
88
-
89
- # If we are running inside an Omniverse app, use that information
90
- try:
91
- import omni.kit.app
92
-
93
- # get the current application
94
- app = omni.kit.app.get_app()
95
- app_name = app.get_app_filename().split(".")[-1]
96
- app_version = app.get_app_version().split("-")[0]
97
- # and where it is installed
98
- appdir = os.path.join(appdir, f"{app_name}-{app_version}")
99
- except ModuleNotFoundError:
100
- # Names should be like: "C:\\Users\\foo\\AppData\\Local\\ov\\pkg\\create-2023.2.3\\launcher.toml"
101
- target = None
102
- target_version = None
103
- for d in glob.glob(os.path.join(appdir, "*", "launcher.toml")):
104
- test_dir = os.path.dirname(d)
105
- # the name will be something like "create-2023.2.3"
106
- name = os.path.basename(test_dir).split("-")
107
- if len(name) != 2:
108
- continue
109
- if name[0] not in ("kit", "create", "view"):
110
- continue
111
- if (target_version is None) or (name[1] > target_version):
112
- target = test_dir
113
- target_version = name[1]
114
- if target is None:
115
- return None
116
- appdir = target
117
-
118
- # Windows: 'kit.bat' in '.' or 'kit' followed by 'kit.exe' in '.' or 'kit'
119
- # Linux: 'kit.sh' in '.' or 'kit' followed by 'kit' in '.' or 'kit'
120
- exe_names = ["kit.sh", "kit"]
121
- if sys.platform.startswith("win"):
122
- exe_names = ["kit.bat", "kit.exe"]
123
-
124
- # look in 4 places...
125
- for dir_name in [appdir, os.path.join(appdir, "kit")]:
126
- for exe_name in exe_names:
127
- if os.path.exists(os.path.join(dir_name, exe_name)):
128
- return os.path.join(dir_name, exe_name)
129
-
130
- return None
131
-
132
252
  def _check_modules(self) -> None:
133
253
  """Verify that the Python interpreter is correct
134
254
 
@@ -361,9 +481,15 @@ class Omniverse:
361
481
  update_cmd += f"{prefix}timesteps=1"
362
482
  prefix = "&"
363
483
  if line_width != 0.0:
364
- # only in 2025 R2 and beyond
365
- if self._ensight._session.ensight_version_check("2025 R2", exception=False):
366
- update_cmd += f"{prefix}line_width={line_width}"
484
+ add_linewidth = False
485
+ if isinstance(self._ensight, ModuleType):
486
+ add_linewidth = True
487
+ else:
488
+ # only in 2025 R2 and beyond
489
+ if self._ensight._session.ensight_version_check("2025 R2", exception=False):
490
+ add_linewidth = True
491
+ if add_linewidth:
492
+ update_cmd += f"{prefix}ANSYS_linewidth={line_width}"
367
493
  prefix = "&"
368
494
  self._check_modules()
369
495
  if not self.is_running_omniverse():
@@ -78,8 +78,7 @@ class OmniverseGeometryServer(object):
78
78
  normalize_geometry: bool = False,
79
79
  dsg_uri: str = "",
80
80
  monitor_directory: str = "",
81
- line_width: float = -0.0001,
82
- use_lines: bool = False,
81
+ line_width: float = 0.0,
83
82
  ) -> None:
84
83
  self._dsg_uri = dsg_uri
85
84
  self._destination = destination
@@ -96,7 +95,6 @@ class OmniverseGeometryServer(object):
96
95
  self._status_filename: str = ""
97
96
  self._monitor_directory: str = monitor_directory
98
97
  self._line_width = line_width
99
- self._use_lines = use_lines
100
98
 
101
99
  @property
102
100
  def monitor_directory(self) -> Optional[str]:
@@ -173,6 +171,14 @@ class OmniverseGeometryServer(object):
173
171
  def time_scale(self, value: float) -> None:
174
172
  self._time_scale = value
175
173
 
174
+ @property
175
+ def line_width(self) -> float:
176
+ return self._line_width
177
+
178
+ @line_width.setter
179
+ def line_width(self, line_width: float) -> None:
180
+ self._line_width = line_width
181
+
176
182
  def run_server(self, one_shot: bool = False) -> None:
177
183
  """
178
184
  Run a DSG to Omniverse server in process.
@@ -189,11 +195,11 @@ class OmniverseGeometryServer(object):
189
195
 
190
196
  # Build the Omniverse connection
191
197
  omni_link = ov_dsg_server.OmniverseWrapper(
192
- destination=self._destination, line_width=self._line_width, use_lines=self._use_lines
198
+ destination=self._destination, line_width=self.line_width
193
199
  )
194
200
  logging.info("Omniverse connection established.")
195
201
 
196
- # parse the DSG USI
202
+ # parse the DSG URI
197
203
  parsed = urlparse(self.dsg_uri)
198
204
  port = parsed.port
199
205
  host = parsed.hostname
@@ -223,7 +229,10 @@ class OmniverseGeometryServer(object):
223
229
 
224
230
  # until the link is dropped, continue
225
231
  while not dsg_link.is_shutdown() and not self._shutdown:
232
+ # Reset the line width to the CLI default before each update
233
+ omni_link.line_width = self.line_width
226
234
  dsg_link.handle_one_update()
235
+
227
236
  if one_shot:
228
237
  break
229
238
 
@@ -276,7 +285,7 @@ class OmniverseGeometryServer(object):
276
285
 
277
286
  # Build the Omniverse connection
278
287
  omni_link = ov_dsg_server.OmniverseWrapper(
279
- destination=self._destination, line_width=self._line_width, use_lines=self._use_lines
288
+ destination=self._destination, line_width=self.line_width
280
289
  )
281
290
  logging.info("Omniverse connection established.")
282
291
 
@@ -347,6 +356,8 @@ class OmniverseGeometryServer(object):
347
356
  logging.warning("Time values not currently supported.")
348
357
  if len(files_to_process) > 1:
349
358
  logging.warning("Multiple glb files not currently fully supported.")
359
+ # Reset the line width to the CLI default before each update
360
+ omni_link.line_width = self.line_width
350
361
  # Upload the files
351
362
  glb_link.start_uploads([timeline[0], timeline[-1]])
352
363
  for glb_file, timestamp in zip(files_to_process, file_timestamps):
@@ -463,13 +474,12 @@ if __name__ == "__main__":
463
474
  line_default = float(line_default)
464
475
  except ValueError:
465
476
  line_default = None
466
- # Potential future default: -0.0001
467
477
  parser.add_argument(
468
478
  "--line_width",
469
479
  metavar="line_width",
470
480
  default=line_default,
471
481
  type=float,
472
- help=f"Width of lines: >0=absolute size. <0=fraction of diagonal. 0=wireframe. Default: {line_default}",
482
+ help=f"Width of lines: >0=absolute size. <0=fraction of diagonal. 0=none. Default: {line_default}",
473
483
  )
474
484
 
475
485
  # parse the command line
@@ -492,8 +502,7 @@ if __name__ == "__main__":
492
502
  logging.basicConfig(**log_args) # type: ignore
493
503
 
494
504
  # size of lines in data units or fraction of bounding box diagonal
495
- use_lines = args.line_width is not None
496
- line_width = -0.0001
505
+ line_width = 0.0
497
506
  if args.line_width is not None:
498
507
  line_width = args.line_width
499
508
 
@@ -508,7 +517,6 @@ if __name__ == "__main__":
508
517
  vrmode=not args.include_camera,
509
518
  temporal=args.temporal,
510
519
  line_width=line_width,
511
- use_lines=use_lines,
512
520
  )
513
521
 
514
522
  # run the server
@@ -41,8 +41,7 @@ class OmniverseWrapper(object):
41
41
  self,
42
42
  live_edit: bool = False,
43
43
  destination: str = "",
44
- line_width: float = -0.0001,
45
- use_lines: bool = False,
44
+ line_width: float = 0.0,
46
45
  ) -> None:
47
46
  self._cleaned_index = 0
48
47
  self._cleaned_names: dict = {}
@@ -61,7 +60,6 @@ class OmniverseWrapper(object):
61
60
  self.destination = destination
62
61
 
63
62
  self._line_width = line_width
64
- self._use_lines = use_lines
65
63
 
66
64
  @property
67
65
  def destination(self) -> str:
@@ -82,10 +80,6 @@ class OmniverseWrapper(object):
82
80
  def line_width(self, line_width: float) -> None:
83
81
  self._line_width = line_width
84
82
 
85
- @property
86
- def use_lines(self) -> bool:
87
- return self._use_lines
88
-
89
83
  def shutdown(self) -> None:
90
84
  """
91
85
  Shutdown the connection to Omniverse cleanly.
@@ -582,7 +576,8 @@ class OmniverseWrapper(object):
582
576
  pbrShader.CreateInput("specularColor", Sdf.ValueTypeNames.Color3f).Set(color)
583
577
 
584
578
  material.CreateSurfaceOutput().ConnectToSource(pbrShader.ConnectableAPI(), "surface")
585
- UsdShade.MaterialBindingAPI(mesh).Bind(material)
579
+ mat_binding_api = UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim())
580
+ mat_binding_api.Bind(material)
586
581
 
587
582
  return material
588
583
 
@@ -639,7 +634,8 @@ class OmniverseWrapper(object):
639
634
  cam = geom_cam.GetCamera()
640
635
  # LOL, not sure why is might be correct, but so far it seems to work???
641
636
  cam.focalLength = camera.fieldofview
642
- cam.clippingRange = Gf.Range1f(0.1, 10)
637
+ dist = (target_pos - cam_pos).GetLength()
638
+ cam.clippingRange = Gf.Range1f(0.1 * dist, 10.0 * dist)
643
639
  look_at = Gf.Matrix4d()
644
640
  look_at.SetLookAt(cam_pos, target_pos, up_vec)
645
641
  trans_row = look_at.GetRow(3)
@@ -723,14 +719,83 @@ class OmniverseUpdateHandler(UpdateHandler):
723
719
  self._group_prims: Dict[int, Any] = dict()
724
720
  self._root_prim = None
725
721
  self._sent_textures = False
722
+ self._updated_camera = False
726
723
 
727
724
  def add_group(self, id: int, view: bool = False) -> None:
728
725
  super().add_group(id, view)
729
726
  group = self.session.groups[id]
727
+
730
728
  if not view:
729
+ # Capture changes in line/sphere sizes if it was not set from cli
730
+ width = self.get_dsg_cmd_attribute(group, "ANSYS_linewidth")
731
+ if width:
732
+ try:
733
+ self._omni.line_width = float(width)
734
+ except ValueError:
735
+ pass
736
+
731
737
  parent_prim = self._group_prims[group.parent_id]
738
+ # get the EnSight object type and the transform matrix
732
739
  obj_type = self.get_dsg_cmd_attribute(group, "ENS_OBJ_TYPE")
733
- matrix = self.group_matrix(group)
740
+ matrix = group.matrix4x4
741
+ # Is this a "case" group (it will contain part of the camera view in the matrix)
742
+ if obj_type == "ENS_CASE":
743
+ if (not self.session.vrmode) and (not self._updated_camera):
744
+ # if in camera mode, we need to update the camera matrix so we can
745
+ # use the identity matrix on this group. The camera should have been
746
+ # created in the "view" handler
747
+ cam_name = "/Root/Cam"
748
+ cam_prim = self._omni._stage.GetPrimAtPath(cam_name) # type: ignore
749
+ geom_cam = UsdGeom.Camera(cam_prim)
750
+ # get the camera
751
+ cam = geom_cam.GetCamera()
752
+ c = cam.transform
753
+ m = Gf.Matrix4d(*matrix).GetTranspose()
754
+ # move the model transform to the camera transform
755
+ cam.transform = c * m.GetInverse()
756
+ # set the updated camera
757
+ geom_cam.SetFromCamera(cam)
758
+ # apply the inverse cam transform to move the center of interest
759
+ # from data space to camera space
760
+ coi_attr = cam_prim.GetAttribute("omni:kit:centerOfInterest")
761
+ if coi_attr.IsValid():
762
+ coi_data = coi_attr.Get()
763
+ coi_cam = (
764
+ Gf.Vec4d(coi_data[0], coi_data[1], coi_data[2], 1.0)
765
+ * cam.transform.GetInverse()
766
+ )
767
+ coi_attr.Set(
768
+ Gf.Vec3d(
769
+ coi_cam[0] / coi_cam[3],
770
+ coi_cam[1] / coi_cam[3],
771
+ coi_cam[2] / coi_cam[3],
772
+ )
773
+ )
774
+ # use the camera view by default
775
+ self._omni._stage.GetRootLayer().customLayerData = { # type: ignore
776
+ "cameraSettings": {"boundCamera": "/Root/Cam"}
777
+ }
778
+
779
+ # We only want to do this once
780
+ self._updated_camera = True
781
+ matrix = [
782
+ 1.0,
783
+ 0.0,
784
+ 0.0,
785
+ 0.0,
786
+ 0.0,
787
+ 1.0,
788
+ 0.0,
789
+ 0.0,
790
+ 0.0,
791
+ 0.0,
792
+ 1.0,
793
+ 0.0,
794
+ 0.0,
795
+ 0.0,
796
+ 0.0,
797
+ 1.0,
798
+ ]
734
799
  prim = self._omni.create_dsg_group(
735
800
  group.name, parent_prim, matrix=matrix, obj_type=obj_type
736
801
  )
@@ -801,40 +866,37 @@ class OmniverseUpdateHandler(UpdateHandler):
801
866
  first_timestep=(self.session.cur_timeline[0] == self.session.time_limits[0]),
802
867
  mat_info=mat_info,
803
868
  )
804
- if self._omni.use_lines:
805
- command, verts, tcoords, var_cmd = part.line_rep()
806
- if command is not None:
807
- # If there are no triangle (ideally if these are not hidden line
808
- # edges), then use the base color for the part. If there are
809
- # triangles, then assume these are hidden line edges and use the
810
- # line_color.
811
- line_color = color
812
- if has_triangles:
813
- line_color = [
814
- part.cmd.line_color[0] * part.cmd.diffuse,
815
- part.cmd.line_color[1] * part.cmd.diffuse,
816
- part.cmd.line_color[2] * part.cmd.diffuse,
817
- part.cmd.line_color[3],
818
- ]
819
- # TODO: texture coordinates on lines are current invalid in OV
820
- var_cmd = None
821
- tcoords = None
822
- # Generate the lines
823
- _ = self._omni.create_dsg_lines(
824
- name,
825
- obj_id,
826
- part.hash,
827
- parent_prim,
828
- verts,
829
- tcoords,
830
- matrix=matrix,
831
- diffuse=line_color,
832
- variable=var_cmd,
833
- timeline=self.session.cur_timeline,
834
- first_timestep=(
835
- self.session.cur_timeline[0] == self.session.time_limits[0]
836
- ),
837
- )
869
+ command, verts, tcoords, var_cmd = part.line_rep()
870
+ if command is not None:
871
+ # If there are no triangle (ideally if these are not hidden line
872
+ # edges), then use the base color for the part. If there are
873
+ # triangles, then assume these are hidden line edges and use the
874
+ # line_color.
875
+ line_color = color
876
+ if has_triangles:
877
+ line_color = [
878
+ part.cmd.line_color[0] * part.cmd.diffuse,
879
+ part.cmd.line_color[1] * part.cmd.diffuse,
880
+ part.cmd.line_color[2] * part.cmd.diffuse,
881
+ part.cmd.line_color[3],
882
+ ]
883
+ # TODO: texture coordinates on lines are current invalid in OV
884
+ var_cmd = None
885
+ tcoords = None
886
+ # Generate the lines
887
+ _ = self._omni.create_dsg_lines(
888
+ name,
889
+ obj_id,
890
+ part.hash,
891
+ parent_prim,
892
+ verts,
893
+ tcoords,
894
+ matrix=matrix,
895
+ diffuse=line_color,
896
+ variable=var_cmd,
897
+ timeline=self.session.cur_timeline,
898
+ first_timestep=(self.session.cur_timeline[0] == self.session.time_limits[0]),
899
+ )
838
900
 
839
901
  elif part.cmd.render == part.cmd.NODES:
840
902
  command, verts, sizes, colors, var_cmd = part.point_rep()
@@ -875,6 +937,8 @@ class OmniverseUpdateHandler(UpdateHandler):
875
937
  # Upload a material to the Omniverse server
876
938
  self._omni.uploadMaterial()
877
939
  self._sent_textures = False
940
+ # We want to update the camera a single time within this update
941
+ self._updated_camera = False
878
942
 
879
943
  def end_update(self) -> None:
880
944
  super().end_update()
@@ -132,7 +132,6 @@ class GLBSession(dsg_server.DSGSession):
132
132
  """
133
133
  mesh = self._gltf.meshes[meshid]
134
134
  for prim_idx, prim in enumerate(mesh.primitives):
135
- # TODO: line width/point size
136
135
  # POINTS, LINES, TRIANGLES, LINE_LOOP, LINE_STRIP, TRIANGLE_STRIP, TRIANGLE_FAN
137
136
  mode = prim.mode
138
137
  if mode not in (
@@ -147,9 +146,7 @@ class GLBSession(dsg_server.DSGSession):
147
146
  self.warn(f"Unhandled connectivity detected: {mode}. Geometry skipped.")
148
147
  continue
149
148
  glb_materialid = prim.material
150
-
151
149
  line_width = self._callback_handler._omni.line_width
152
- # TODO: override from scene extension: ANSYS_linewidth
153
150
 
154
151
  # GLB Prim -> DSG Part
155
152
  part_name = f"{parentname}_prim{prim_idx}_"
@@ -583,7 +580,16 @@ class GLBSession(dsg_server.DSGSession):
583
580
  view_pb.fieldofview = camera.perspective.yfov
584
581
  view_pb.aspectratio = camera.aspectratio.aspectRatio
585
582
  self._handle_update_command(cmd)
586
- for node_id in self._gltf.scenes[scene_idx].nodes:
583
+ # walk the scene nodes RJF
584
+ scene = self._gltf.scenes[scene_idx]
585
+ try:
586
+ if self._callback_handler._omni.line_width == 0.0:
587
+ width = float(scene.extensions["ANSYS_linewidth"]["linewidth"])
588
+ self._callback_handler._omni.line_width = width
589
+ except (KeyError, ValueError):
590
+ # in the case where the extension does not exist or is mal-formed
591
+ pass
592
+ for node_id in scene.nodes:
587
593
  self._walk_node(node_id, view_pb.id)
588
594
  self._finish_part()
589
595
 
@@ -621,11 +627,22 @@ class GLBSession(dsg_server.DSGSession):
621
627
  List[float]
622
628
  The computed timeline.
623
629
  """
624
- # if ANSYS_scene_time is used, time ranges will come from there
625
- if "ANSYS_scene_time" in self._gltf.scenes[scene_idx].extensions:
626
- return self._gltf.scenes[scene_idx].extensions["ANSYS_scene_time"]
627
- # if there is only one scene, then use the input timeline
628
630
  num_scenes = len(self._gltf.scenes)
631
+ # if ANSYS_scene_timevalue is used, time ranges will come from there
632
+ try:
633
+ t0 = self._gltf.scenes[scene_idx].extensions["ANSYS_scene_timevalue"]["timevalue"]
634
+ idx = scene_idx + 1
635
+ if idx >= num_scenes:
636
+ idx = scene_idx
637
+ t1 = self._gltf.scenes[idx].extensions["ANSYS_scene_timevalue"]["timevalue"]
638
+ else:
639
+ t1 = t0
640
+ return [t0, t1]
641
+ except KeyError:
642
+ # If we fail due to dictionary key issue, the extension does not exist or is
643
+ # improperly formatted.
644
+ pass
645
+ # if there is only one scene, then use the input timeline
629
646
  if num_scenes == 1:
630
647
  return input_timeline
631
648
  # if the timeline has zero length, we make it the number of scenes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ansys-pyensight-core
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: A python wrapper for Ansys EnSight
5
5
  Author-email: "ANSYS, Inc." <pyansys.core@ansys.com>
6
6
  Maintainer-email: "ANSYS, Inc." <pyansys.core@ansys.com>
@@ -4,7 +4,7 @@ ansys/pyensight/core/deep_pixel_view.html,sha256=6u4mGOuzJiPze8N8pIKJsEGPv5y6-zb
4
4
  ansys/pyensight/core/dockerlauncher.py,sha256=LmgEFEjl0l3D_JaeJu5bbGR28aluHYtzb0-BmrC7ta8,28251
5
5
  ansys/pyensight/core/enscontext.py,sha256=GSKkjZt1QEPyHEQ59EEBgKGMik9vjCdR9coR4uX7fEw,12141
6
6
  ansys/pyensight/core/enshell_grpc.py,sha256=-OxSdFI_p3DmQnqh1jT_a_aSh_w-EUD2IaWGKxrnyjI,17122
7
- ansys/pyensight/core/ensight_grpc.py,sha256=0_qv9gUQsWyRSGGtTW84PNNEVegtsCPrnT6oXix-hvc,16415
7
+ ansys/pyensight/core/ensight_grpc.py,sha256=IkntqzHmNzhADZ1VZJtWGpaB3ZlSThcovsbXyliW_Sk,29458
8
8
  ansys/pyensight/core/ensobj.py,sha256=uDtM2KHcAwd4hu5pcUYWbSD729ApHGIvuqZhEq8PxTI,18558
9
9
  ansys/pyensight/core/launch_ensight.py,sha256=iZJM6GdpzGRDLzrv1V2QZ5veIOpNSB5xPpJUFY7rBuo,10254
10
10
  ansys/pyensight/core/launcher.py,sha256=ymwfixwoHO7_c4qOetqccQbZjGT1HjeA7jwPi2JxlmE,10585
@@ -19,10 +19,10 @@ ansys/pyensight/core/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
19
19
  ansys/pyensight/core/utils/adr.py,sha256=XslZhlwcrSGzOlnhzprOv3ju_ppxxsWBjCnQL5KiNms,3570
20
20
  ansys/pyensight/core/utils/dsg_server.py,sha256=FfiTJ7XsTW3OhI2w7PKMX5SLVoznGgZ6In-Q_wpkggE,45017
21
21
  ansys/pyensight/core/utils/export.py,sha256=UAJQcrElo3esQD0CWdxxjMQ8yE1vB4cdAhF33_uZfQw,22605
22
- ansys/pyensight/core/utils/omniverse.py,sha256=vzPAyPH0cd5fJaeVv3iNPE4shPtuamwEIavq_OBsW48,14716
23
- ansys/pyensight/core/utils/omniverse_cli.py,sha256=m5TgxHxDVVSqEY4kGss0OwV-aYHDVX076JPwKlx1fp0,20042
24
- ansys/pyensight/core/utils/omniverse_dsg_server.py,sha256=gW9uQfHmuSYS9VrwI6jkn6BxIfu3MoxUbK7QlN6Cuec,35482
25
- ansys/pyensight/core/utils/omniverse_glb_server.py,sha256=K-LLXrQsYES19ai6D8BqmxH6PjvGLIqdMVdbaSnfHQA,29118
22
+ ansys/pyensight/core/utils/omniverse.py,sha256=Y16_nXjfymnXpJmddAkqNnVCWGr8RTjHqA6JSNz8GA8,18483
23
+ ansys/pyensight/core/utils/omniverse_cli.py,sha256=aVYu4HsSZBl7A9_xU3DfItNB-hpxCdMPXm_oMAOU_HQ,20261
24
+ ansys/pyensight/core/utils/omniverse_dsg_server.py,sha256=Bnc99LU2216Dk2t8v0rVNH8sJm8pQiYwqkcH1N5zTvE,38384
25
+ ansys/pyensight/core/utils/omniverse_glb_server.py,sha256=gP6NEmrmqB1ALFGGLkNwnIhn7JWHylRvyIb8JbXrbmA,29879
26
26
  ansys/pyensight/core/utils/parts.py,sha256=222XFRCjLgH7hho-cK9JrGCg3-KlTf54KIgc7y50sTE,52173
27
27
  ansys/pyensight/core/utils/query.py,sha256=OXKDbf1sOTX0sUvtKcp64LhVl-BcrEsE43w8uMxLOYI,19828
28
28
  ansys/pyensight/core/utils/readers.py,sha256=cNNzrE5pjy4wpQKWAzIIJfJCSpY3HU43az02f5I3pVU,11968
@@ -30,7 +30,7 @@ ansys/pyensight/core/utils/support.py,sha256=QI3z9ex7zJxjFbkCPba9DWqWgPFIThORqr0
30
30
  ansys/pyensight/core/utils/variables.py,sha256=ZUiJdDIeRcowrnLXaJQqGwA0RbrfXhc1s4o4v9A4PiY,95133
31
31
  ansys/pyensight/core/utils/views.py,sha256=ZKhJ6vMT7Rdd4bwJ0egMYTV7-D7Q7I19fF2_j_CMQ0o,12489
32
32
  ansys/pyensight/core/utils/resources/Materials/000_sky.exr,sha256=xAR1gFd2uxPZDnvgfegdhEhRaqKtZldQDiR_-1rHKO0,8819933
33
- ansys_pyensight_core-0.9.1.dist-info/LICENSE,sha256=qQWivZ12ETN5l3QxvTARY-QI5eoRRlyHdwLlAj0Bg5I,1089
34
- ansys_pyensight_core-0.9.1.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
35
- ansys_pyensight_core-0.9.1.dist-info/METADATA,sha256=wnkwJoIFTKw7ZiJTeN2_w54IN8wLj2VVO-gF6TfjXVI,12113
36
- ansys_pyensight_core-0.9.1.dist-info/RECORD,,
33
+ ansys_pyensight_core-0.9.2.dist-info/LICENSE,sha256=qQWivZ12ETN5l3QxvTARY-QI5eoRRlyHdwLlAj0Bg5I,1089
34
+ ansys_pyensight_core-0.9.2.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
35
+ ansys_pyensight_core-0.9.2.dist-info/METADATA,sha256=KkO1m505t8Zbu76uwkfORfA33vR2HpHwyros1mwRFvs,12113
36
+ ansys_pyensight_core-0.9.2.dist-info/RECORD,,