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

@@ -1,912 +1,1085 @@
1
- import hashlib
2
- import json
3
- import logging
4
- import os
5
- import queue
6
- import sys
7
- import threading
8
- import time
9
- from typing import Any, Dict, List, Optional
10
-
11
- from ansys.api.pyensight.v0 import dynamic_scene_graph_pb2
12
- from ansys.pyensight.core import ensight_grpc
13
- import numpy
14
-
15
-
16
- class Part(object):
17
- def __init__(self, session: "DSGSession"):
18
- """
19
- This object roughly represents an EnSight "Part". It contains the connectivity,
20
- coordinates, normals and texture coordinate information for one DSG entity
21
-
22
- This object stores basic geometry information coming from the DSG protocol. The
23
- update_geom() method can parse an "UpdateGeom" protobuffer and merges the results
24
- into the Part object.
25
-
26
- Parameters
27
- ----------
28
- session:
29
- The DSG connection session object.
30
- """
31
- self.session = session
32
- self.conn_tris = numpy.array([], dtype="int32")
33
- self.conn_lines = numpy.array([], dtype="int32")
34
- self.coords = numpy.array([], dtype="float32")
35
- self.normals = numpy.array([], dtype="float32")
36
- self.normals_elem = False
37
- self.tcoords = numpy.array([], dtype="float32")
38
- self.tcoords_elem = False
39
- self.node_sizes = numpy.array([], dtype="float32")
40
- self.cmd: Optional[Any] = None
41
- self.hash = hashlib.new("sha256")
42
- self.reset()
43
-
44
- def reset(self, cmd: Any = None) -> None:
45
- self.conn_tris = numpy.array([], dtype="int32")
46
- self.conn_lines = numpy.array([], dtype="int32")
47
- self.coords = numpy.array([], dtype="float32")
48
- self.normals = numpy.array([], dtype="float32")
49
- self.normals_elem = False
50
- self.tcoords = numpy.array([], dtype="float32")
51
- self.tcoords_var_id = None
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"))
57
- self.cmd = cmd
58
-
59
- def update_geom(self, cmd: dynamic_scene_graph_pb2.UpdateGeom) -> None:
60
- """
61
- Merge an update geometry command into the numpy buffers being cached in this object
62
-
63
- Parameters
64
- ----------
65
- cmd:
66
- This is an array update command. It could be for coordinates, normals, variables, connectivity, etc.
67
- """
68
- if cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.COORDINATES:
69
- if self.coords.size != cmd.total_array_size:
70
- self.coords = numpy.resize(self.coords, cmd.total_array_size)
71
- self.coords[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
72
- elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.TRIANGLES:
73
- if self.conn_tris.size != cmd.total_array_size:
74
- self.conn_tris = numpy.resize(self.conn_tris, cmd.total_array_size)
75
- self.conn_tris[cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)] = cmd.int_array
76
- elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.LINES:
77
- if self.conn_lines.size != cmd.total_array_size:
78
- self.conn_lines = numpy.resize(self.conn_lines, cmd.total_array_size)
79
- self.conn_lines[
80
- cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)
81
- ] = cmd.int_array
82
- elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS) or (
83
- cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_NORMALS
84
- ):
85
- self.normals_elem = cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS
86
- if self.normals.size != cmd.total_array_size:
87
- self.normals = numpy.resize(self.normals, cmd.total_array_size)
88
- self.normals[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
89
- elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE) or (
90
- cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_VARIABLE
91
- ):
92
- # Get the variable definition
93
- if cmd.variable_id in self.session.variables:
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"))
113
-
114
- def nodal_surface_rep(self):
115
- """
116
- This function processes the geometry arrays and converts them into nodal representation.
117
- It will duplicate triangles as needed (to preserve element normals) and will convert
118
- variable data into texture coordinates.
119
-
120
- Returns
121
- -------
122
- On failure, the method returns None for the first return value. The returned tuple is:
123
-
124
- (part_command, vertices, connectivity, normals, tex_coords, var_command)
125
-
126
- part_command: UPDATE_PART command object
127
- vertices: numpy array of the nodal coordinates
128
- connectivity: numpy array of the triangle indices into the vertices array
129
- normals: numpy array of per vertex normal values (optional)
130
- tcoords: numpy array of per vertex texture coordinates (optional)
131
- var_command: UPDATE_VARIABLE command object for the variable the texture coordinate correspond to, if any
132
- """
133
- if self.cmd is None:
134
- return None, None, None, None, None, None
135
- if self.conn_tris.size == 0:
136
- self.session.log(f"Note: part '{self.cmd.name}' contains no triangles.")
137
- return None, None, None, None, None, None
138
- verts = self.coords
139
- self.normalize_verts(verts)
140
-
141
- conn = self.conn_tris
142
- normals = self.normals
143
- tcoords = None
144
- if self.tcoords.size:
145
- tcoords = self.tcoords
146
- if self.tcoords_elem or self.normals_elem:
147
- verts_per_prim = 3
148
- num_prims = int(conn.size / verts_per_prim)
149
- # "flatten" the triangles to move values from elements to nodes
150
- new_verts = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
151
- new_conn = numpy.ndarray((num_prims * verts_per_prim,), dtype="int32")
152
- new_tcoords = None
153
- if tcoords is not None:
154
- # remember that the input values are 1D at this point, we will expand to 2D later
155
- new_tcoords = numpy.ndarray((num_prims * verts_per_prim,), dtype="float32")
156
- new_normals = None
157
- if normals is not None:
158
- if normals.size == 0:
159
- self.session.log("Warning: zero length normals!")
160
- else:
161
- new_normals = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
162
- j = 0
163
- for i0 in range(num_prims):
164
- for i1 in range(verts_per_prim):
165
- idx = conn[i0 * verts_per_prim + i1]
166
- # new connectivity (identity)
167
- new_conn[j] = j
168
- # copy the vertex
169
- new_verts[j * 3 + 0] = verts[idx * 3 + 0]
170
- new_verts[j * 3 + 1] = verts[idx * 3 + 1]
171
- new_verts[j * 3 + 2] = verts[idx * 3 + 2]
172
- if new_normals is not None:
173
- if self.normals_elem:
174
- # copy the normal associated with the face
175
- new_normals[j * 3 + 0] = normals[i0 * 3 + 0]
176
- new_normals[j * 3 + 1] = normals[i0 * 3 + 1]
177
- new_normals[j * 3 + 2] = normals[i0 * 3 + 2]
178
- else:
179
- # copy the same normal as the vertex
180
- new_normals[j * 3 + 0] = normals[idx * 3 + 0]
181
- new_normals[j * 3 + 1] = normals[idx * 3 + 1]
182
- new_normals[j * 3 + 2] = normals[idx * 3 + 2]
183
- if new_tcoords is not None:
184
- # remember, 1D texture coords at this point
185
- if self.tcoords_elem:
186
- # copy the texture coord associated with the face
187
- new_tcoords[j] = tcoords[i0]
188
- else:
189
- # copy the same texture coord as the vertex
190
- new_tcoords[j] = tcoords[idx]
191
- j += 1
192
- # new arrays.
193
- verts = new_verts
194
- conn = new_conn
195
- normals = new_normals
196
- if tcoords is not None:
197
- tcoords = new_tcoords
198
-
199
- var_cmd = None
200
- # texture coords need transformation from variable value to [ST]
201
- if tcoords is not None:
202
- var_dsg_id = self.cmd.color_variableid
203
- var_cmd = self.session.variables[var_dsg_id]
204
- v_min = None
205
- v_max = None
206
- for lvl in var_cmd.levels:
207
- if (v_min is None) or (v_min > lvl.value):
208
- v_min = lvl.value
209
- if (v_max is None) or (v_max < lvl.value):
210
- v_max = lvl.value
211
- var_minmax = [v_min, v_max]
212
- # build a power of two x 1 texture
213
- num_texels = int(len(var_cmd.texture) / 4)
214
- half_texel = 1 / (num_texels * 2.0)
215
- num_verts = int(verts.size / 3)
216
- tmp = numpy.ndarray((num_verts * 2,), dtype="float32")
217
- tmp.fill(0.5) # fill in the T coordinate...
218
- tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels
219
- # if the range is 0, adjust the min by -1. The result is that the texture
220
- # coords will get mapped to S=1.0 which is what EnSight does in this situation
221
- if (var_minmax[1] - var_minmax[0]) == 0.0:
222
- var_minmax[0] = var_minmax[0] - 1.0
223
- var_width = var_minmax[1] - var_minmax[0]
224
- for idx in range(num_verts):
225
- # normalized S coord value (clamp)
226
- s = (tcoords[idx] - var_minmax[0]) / var_width
227
- if s < 0.0:
228
- s = 0.0
229
- if s > 1.0:
230
- s = 1.0
231
- # map to the texture range and set the S value
232
- tmp[idx * 2] = s * tex_width + half_texel
233
- tcoords = tmp
234
-
235
- self.session.log(
236
- f"Part '{self.cmd.name}' defined: {self.coords.size/3} verts, {self.conn_tris.size/3} tris."
237
- )
238
- command = self.cmd
239
-
240
- return command, verts, conn, normals, tcoords, var_cmd
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
-
377
-
378
- class UpdateHandler(object):
379
- """
380
- This class serves as the interface between a DSGSession and a hosting application.
381
- The DSGSession processes the general aspects of the gRPC pipeline and collects the
382
- various DSG objects into collections of: groups, variables, etc. It also coalesces
383
- the individual array updates into a "Part" object which represents a single addressable
384
- mesh chunk.
385
- UpdateHandler methods are called as the various update happen, and it is called when
386
- a mesh chunk has been entirely defined. In most scenarios, a subclass of UpdateHandler
387
- is passed to the DSGSession to handshake the mesh data to the application target.
388
- """
389
-
390
- def __init__(self) -> None:
391
- self._session: "DSGSession"
392
-
393
- @property
394
- def session(self) -> "DSGSession":
395
- """The session object this handler has been associated with"""
396
- return self._session
397
-
398
- @session.setter
399
- def session(self, session: "DSGSession") -> None:
400
- self._session = session
401
-
402
- def add_group(self, id: int, view: bool = False) -> None:
403
- """Called when a new group command has been added: self.session.groups[id]"""
404
- if view:
405
- self.session.log(f"Adding view: {self.session.groups[id]}")
406
- else:
407
- self.session.log(f"Adding group: {self.session.groups[id].name}")
408
-
409
- def add_variable(self, id: int) -> None:
410
- """Called when a new group command has been added: self.session.variables[id]"""
411
- self.session.log(f"Adding variable: {self.session.variables[id].name}")
412
-
413
- def finalize_part(self, part: Part) -> None:
414
- """Called when all the updates on a Part object have been completed.
415
-
416
- Note: this superclass method should be called after the subclass has processed
417
- the part geometry as the saved part command will be destroyed by this call.
418
- """
419
- if part.cmd:
420
- self.session.log(f"Part finalized: {part.cmd.name}")
421
- part.cmd = None
422
-
423
- def start_connection(self) -> None:
424
- """A new gRPC connection has been established: self.session.grpc"""
425
- grpc = self.session.grpc
426
- self.session.log(f"gRPC connection established to: {grpc.host}:{grpc.port}")
427
-
428
- def end_connection(self) -> None:
429
- """The previous gRPC connection has been closed"""
430
- self.session.log("gRPC connection closed")
431
-
432
- def begin_update(self) -> None:
433
- """A new scene update is about to begin"""
434
- self.session.log("Begin update ------------------------")
435
-
436
- def end_update(self) -> None:
437
- """The scene update is complete"""
438
- self.session.log("End update ------------------------")
439
-
440
- def get_dsg_cmd_attribute(self, obj: Any, name: str, default: Any = None) -> Optional[str]:
441
- """Utility function to get an attribute from a DSG update object
442
-
443
- Note: UpdateVariable and UpdateGroup commands support generic attributes
444
- """
445
- return obj.attributes.get(name, default)
446
-
447
- def group_matrix(self, group: Any) -> Any:
448
- matrix = group.matrix4x4
449
- # The Case matrix is basically the camera transform. In vrmode, we only want
450
- # the raw geometry, so use the identity matrix.
451
- if (
452
- self.get_dsg_cmd_attribute(group, "ENS_OBJ_TYPE") == "ENS_CASE"
453
- ) and self.session.vrmode:
454
- matrix = [
455
- 1.0,
456
- 0.0,
457
- 0.0,
458
- 0.0,
459
- 0.0,
460
- 1.0,
461
- 0.0,
462
- 0.0,
463
- 0.0,
464
- 0.0,
465
- 1.0,
466
- 0.0,
467
- 0.0,
468
- 0.0,
469
- 0.0,
470
- 1.0,
471
- ]
472
- return matrix
473
-
474
-
475
- class DSGSession(object):
476
- def __init__(
477
- self,
478
- port: int = 12345,
479
- host: str = "127.0.0.1",
480
- security_code: str = "",
481
- verbose: int = 0,
482
- normalize_geometry: bool = False,
483
- vrmode: bool = False,
484
- time_scale: float = 1.0,
485
- handler: UpdateHandler = UpdateHandler(),
486
- ):
487
- """
488
- Manage a gRPC connection and link it to an UpdateHandler instance
489
-
490
- This class makes a DSG gRPC connection via the specified port and host (leveraging
491
- the passed security code). As DSG protobuffers arrive, they are merged into Part
492
- object instances and the UpdateHandler is invoked to further process them.
493
-
494
- Parameters
495
- ----------
496
- port : int
497
- The port number the EnSight gRPC service is running on.
498
- The default is ``12345``.
499
- host : str
500
- Name of the host that the EnSight gRPC service is running on.
501
- The default is ``"127.0.0.1"``, which is the localhost.
502
- security_code : str
503
- Shared security code for validating the gRPC communication.
504
- The default is ``""``.
505
- verbose : int
506
- The verbosity level. If set to 1 or higher the class will call logging.info
507
- for log output. The default is ``0``.
508
- normalize_geometry : bool
509
- If True, the scene coordinates will be remapped into the volume [-1,-1,-1] - [1,1,1]
510
- The default is not to remap coordinates.
511
- vrmode : bool
512
- If True, do not include the EnSight camera in the generated view group. The default
513
- is to include the EnSight view in the scene transformations.
514
- time_scale : float
515
- All DSG protobuffers time values will be multiplied by this factor after
516
- being received. The default is ``1.0``.
517
- handler : UpdateHandler
518
- This is an UpdateHandler subclass that is called back when the state of
519
- a scene transfer changes. For example, methods are called when the
520
- transfer begins or ends and when a Part (mesh block) is ready for processing.
521
- """
522
- super().__init__()
523
- self._grpc = ensight_grpc.EnSightGRPC(port=port, host=host, secret_key=security_code)
524
- self._callback_handler = handler
525
- self._verbose = verbose
526
- self._thread: Optional[threading.Thread] = None
527
- self._message_queue: queue.Queue = queue.Queue() # Messages coming from EnSight
528
- self._dsg_queue: Optional[queue.SimpleQueue] = None # Outgoing messages to EnSight
529
- self._shutdown = False
530
- self._dsg = None
531
- self._normalize_geometry = normalize_geometry
532
- self._vrmode = vrmode
533
- self._time_scale = time_scale
534
- self._time_limits = [
535
- sys.float_info.max,
536
- -sys.float_info.max,
537
- ] # Min/max across all time steps
538
- self._mesh_block_count = 0
539
- self._variables: Dict[int, Any] = dict()
540
- self._groups: Dict[int, Any] = dict()
541
- self._part: Part = Part(self)
542
- self._scene_bounds: Optional[List] = None
543
- self._cur_timeline: List = [0.0, 0.0] # Start/End time for current update
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)
548
-
549
- @property
550
- def scene_bounds(self) -> Optional[List]:
551
- return self._scene_bounds
552
-
553
- @property
554
- def mesh_block_count(self) -> int:
555
- return self._mesh_block_count
556
-
557
- @property
558
- def vrmode(self) -> bool:
559
- return self._vrmode
560
-
561
- @vrmode.setter
562
- def vrmode(self, value: bool) -> None:
563
- self._vrmode = value
564
-
565
- @property
566
- def normalize_geometry(self) -> bool:
567
- return self._normalize_geometry
568
-
569
- @normalize_geometry.setter
570
- def normalize_geometry(self, value: bool) -> None:
571
- self._normalize_geometry = value
572
-
573
- @property
574
- def variables(self) -> dict:
575
- return self._variables
576
-
577
- @property
578
- def groups(self) -> dict:
579
- return self._groups
580
-
581
- @property
582
- def part(self) -> Part:
583
- return self._part
584
-
585
- @property
586
- def time_limits(self) -> List:
587
- return self._time_limits
588
-
589
- @property
590
- def cur_timeline(self) -> List:
591
- return self._cur_timeline
592
-
593
- @cur_timeline.setter
594
- def cur_timeline(self, timeline: List) -> None:
595
- self._cur_timeline = timeline
596
- self._time_limits[0] = min(self._time_limits[0], self._cur_timeline[0])
597
- self._time_limits[1] = max(self._time_limits[1], self._cur_timeline[1])
598
-
599
- @property
600
- def grpc(self) -> ensight_grpc.EnSightGRPC:
601
- return self._grpc
602
-
603
- def log(self, s: str, level: int = 0) -> None:
604
- """Log a string to the logging system
605
-
606
- If the message level is less than the current verbosity,
607
- emit the message.
608
- """
609
- if level < self._verbose:
610
- logging.info(s)
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
-
621
- def start(self) -> int:
622
- """Start a gRPC connection to an EnSight instance
623
-
624
- Make a gRPC connection and start a DSG stream handler.
625
-
626
- Returns
627
- -------
628
- 0 on success, -1 on an error.
629
- """
630
- # Start by setting up and verifying the connection
631
- self._grpc.connect()
632
- if not self._grpc.is_connected():
633
- self.log(f"Unable to establish gRPC connection to: {self._grpc.host}:{self._grpc.port}")
634
- return -1
635
- # Streaming API requires an iterator, so we make one from a queue
636
- # it also returns an iterator. self._dsg_queue is the input stream interface
637
- # self._dsg is the returned stream iterator.
638
- if self._dsg is not None:
639
- return 0
640
- self._dsg_queue = queue.SimpleQueue()
641
- self._dsg = self._grpc.dynamic_scene_graph_stream(
642
- iter(self._dsg_queue.get, None) # type:ignore
643
- )
644
- self._thread = threading.Thread(target=self._poll_messages)
645
- if self._thread is not None:
646
- self._thread.start()
647
- self._callback_handler.start_connection()
648
- return 0
649
-
650
- def end(self):
651
- """Stop a gRPC connection to the EnSight instance"""
652
- self._callback_handler.end_connection()
653
- self._grpc.shutdown()
654
- self._shutdown = True
655
- self._thread.join()
656
- self._grpc.shutdown()
657
- self._dsg = None
658
- self._thread = None
659
- self._dsg_queue = None
660
-
661
- def is_shutdown(self):
662
- """Check the service shutdown request status"""
663
- return self._shutdown
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
-
697
- def request_an_update(self, animation: bool = False, allow_spontaneous: bool = True) -> None:
698
- """Start a DSG update
699
- Send a command to the DSG protocol to "init" an update.
700
-
701
- Parameters
702
- ----------
703
- animation:
704
- if True, export all EnSight timesteps.
705
- allow_spontaneous:
706
- if True, allow EnSight to trigger async updates.
707
- """
708
- # Send an INIT command to trigger a stream of update packets
709
- cmd = dynamic_scene_graph_pb2.SceneClientCommand()
710
- cmd.command_type = dynamic_scene_graph_pb2.SceneClientCommand.INIT
711
- # Allow EnSight push commands, but full scene only for now...
712
- cmd.init.allow_spontaneous = allow_spontaneous
713
- cmd.init.include_temporal_geometry = animation
714
- cmd.init.allow_incremental_updates = False
715
- cmd.init.maximum_chunk_size = 1024 * 1024
716
- self._dsg_queue.put(cmd) # type:ignore
717
-
718
- def _poll_messages(self) -> None:
719
- """Core interface to grab DSG events from gRPC and queue them for processing
720
-
721
- This is run by a thread that is monitoring the dsg RPC call for update messages
722
- it places them in _message_queue as it finds them. They are picked up by the
723
- main thread via get_next_message()
724
- """
725
- while not self._shutdown:
726
- try:
727
- self._message_queue.put(next(self._dsg)) # type:ignore
728
- except Exception:
729
- self._shutdown = True
730
- self.log("DSG connection broken, calling exit")
731
- os._exit(0)
732
-
733
- def _get_next_message(self, wait: bool = True) -> Any:
734
- """Get the next queued up protobuffer message
735
-
736
- Called by the main thread to get any messages that were pulled in from the
737
- dsg stream and placed here by _poll_messages()
738
- """
739
- try:
740
- return self._message_queue.get(block=wait)
741
- except queue.Empty:
742
- return None
743
-
744
- def handle_one_update(self) -> None:
745
- """Monitor the DSG stream and handle a single update operation
746
-
747
- Wait until we get the scene update begin message. From there, reset the current
748
- scene buckets and then parse all the incoming commands until we get the scene
749
- update end command. At which point, save the generated stage (started in the
750
- view command handler). Note: Parts are handled with an available bucket at all times.
751
- When a new part update comes in or the scene update end happens, the part is "finished".
752
- """
753
- # An update starts with a UPDATE_SCENE_BEGIN command
754
- cmd = self._get_next_message()
755
- while (cmd is not None) and (
756
- cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_BEGIN
757
- ):
758
- # Look for a begin command
759
- cmd = self._get_next_message()
760
-
761
- # Start anew
762
- self._variables = {}
763
- self._groups = {}
764
- self._part = Part(self)
765
- self._scene_bounds = None
766
- self._mesh_block_count = 0 # reset when a new group shows up
767
- self._callback_handler.begin_update()
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
-
775
- # handle the various commands until UPDATE_SCENE_END
776
- cmd = self._get_next_message()
777
- while (cmd is not None) and (
778
- cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_END
779
- ):
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)
784
- cmd = self._get_next_message()
785
-
786
- # Flush the last part
787
- self._finish_part()
788
-
789
- self._callback_handler.end_update()
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
-
795
- def _handle_update_command(self, cmd: dynamic_scene_graph_pb2.SceneUpdateCommand) -> None:
796
- """Dispatch out a scene update command to the proper handler
797
-
798
- Given a command object, pull out the correct portion of the protobuffer union and
799
- pass it to the appropriate handler.
800
-
801
- Parameters
802
- ----------
803
- cmd:
804
- The command to be dispatched.
805
- """
806
- name = "Unknown"
807
- if cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.DELETE_ID:
808
- name = "Delete IDs"
809
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_PART:
810
- name = "Part update"
811
- tmp = cmd.update_part
812
- self._handle_part(tmp)
813
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GROUP:
814
- name = "Group update"
815
- tmp = cmd.update_group
816
- self._handle_group(tmp)
817
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GEOM:
818
- name = "Geom update"
819
- tmp = cmd.update_geom
820
- self._part.update_geom(tmp)
821
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VARIABLE:
822
- name = "Variable update"
823
- tmp = cmd.update_variable
824
- self._handle_variable(tmp)
825
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VIEW:
826
- name = "View update"
827
- tmp = cmd.update_view
828
- self._handle_view(tmp)
829
- elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_TEXTURE:
830
- name = "Texture update"
831
- self.log(f"{name} --------------------------")
832
-
833
- def _finish_part(self) -> None:
834
- """Complete the current part
835
-
836
- There is always a part being modified. This method completes the current part, committing
837
- it to the handler.
838
- """
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)}")
843
- self._mesh_block_count += 1
844
-
845
- def _handle_part(self, part_cmd: Any) -> None:
846
- """Handle a DSG UPDATE_PART command
847
-
848
- Finish the current part and set up the next part.
849
-
850
- Parameters
851
- ----------
852
- part:
853
- The command coming from the EnSight stream.
854
- """
855
- self._finish_part()
856
- self._part.reset(part_cmd)
857
-
858
- def _handle_group(self, group: Any) -> None:
859
- """Handle a DSG UPDATE_GROUP command
860
-
861
- Parameters
862
- ----------
863
- group:
864
- The command coming from the EnSight stream.
865
- """
866
- # reset current mesh (part) count for unique "part" naming in USD
867
- self._mesh_block_count = 0
868
-
869
- # record the scene bounds in case they are needed later
870
- self._groups[group.id] = group
871
- bounds = group.attributes.get("ENS_SCENE_BOUNDS", None)
872
- if bounds:
873
- minmax = list()
874
- for v in bounds.split(","):
875
- try:
876
- minmax.append(float(v))
877
- except ValueError:
878
- pass
879
- if len(minmax) == 6:
880
- self._scene_bounds = minmax
881
- # callback
882
- self._callback_handler.add_group(group.id)
883
-
884
- def _handle_variable(self, var: Any) -> None:
885
- """Handle a DSG UPDATE_VARIABLE command
886
-
887
- Save off the EnSight variable DSG command object.
888
-
889
- Parameters
890
- ----------
891
- var:
892
- The command coming from the EnSight stream.
893
- """
894
- self._variables[var.id] = var
895
- self._callback_handler.add_variable(var.id)
896
-
897
- def _handle_view(self, view: Any) -> None:
898
- """Handle a DSG UPDATE_VIEW command
899
-
900
- Parameters
901
- ----------
902
- view:
903
- The command coming from the EnSight stream.
904
- """
905
- self._finish_part()
906
- self._scene_bounds = None
907
- self._groups[view.id] = view
908
- if len(view.timeline) == 2:
909
- view.timeline[0] *= self._time_scale
910
- view.timeline[1] *= self._time_scale
911
- self.cur_timeline = [view.timeline[0], view.timeline[1]]
912
- self._callback_handler.add_group(view.id, view=True)
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ import os
5
+ import queue
6
+ import sys
7
+ import threading
8
+ import time
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from ansys.api.pyensight.v0 import dynamic_scene_graph_pb2
12
+ from ansys.pyensight.core import ensight_grpc
13
+ import numpy
14
+
15
+
16
+ class Part(object):
17
+ def __init__(self, session: "DSGSession"):
18
+ """
19
+ This object roughly represents an EnSight "Part". It contains the connectivity,
20
+ coordinates, normals and texture coordinate information for one DSG entity
21
+
22
+ This object stores basic geometry information coming from the DSG protocol. The
23
+ update_geom() method can parse an "UpdateGeom" protobuffer and merges the results
24
+ into the Part object.
25
+
26
+ Parameters
27
+ ----------
28
+ session:
29
+ The DSG connection session object.
30
+ """
31
+ self.session = session
32
+ self.conn_tris = numpy.array([], dtype="int32")
33
+ self.conn_lines = numpy.array([], dtype="int32")
34
+ self.coords = numpy.array([], dtype="float32")
35
+ self.normals = numpy.array([], dtype="float32")
36
+ self.normals_elem = False
37
+ self.tcoords = numpy.array([], dtype="float32")
38
+ self.tcoords_elem = False
39
+ self.node_sizes = numpy.array([], dtype="float32")
40
+ self.cmd: Optional[Any] = None
41
+ self.hash = hashlib.new("sha256")
42
+ self._material: Optional[Any] = None
43
+ self.reset()
44
+
45
+ def reset(self, cmd: Any = None) -> None:
46
+ """
47
+ Reset the part object state to prepare the object
48
+ for a new part representation. Numpy arrays are cleared
49
+ and the state reset.
50
+
51
+ Parameters
52
+ ----------
53
+ cmd: Any
54
+ The DSG command that triggered this reset. Most likely
55
+ this is a UPDATE_PART command.
56
+
57
+ """
58
+ self.conn_tris = numpy.array([], dtype="int32")
59
+ self.conn_lines = numpy.array([], dtype="int32")
60
+ self.coords = numpy.array([], dtype="float32")
61
+ self.normals = numpy.array([], dtype="float32")
62
+ self.normals_elem = False
63
+ self.tcoords = numpy.array([], dtype="float32")
64
+ self.tcoords_var_id = None
65
+ self.tcoords_elem = False
66
+ self.node_sizes = numpy.array([], dtype="float32")
67
+ self.hash = hashlib.new("sha256")
68
+ if cmd is not None:
69
+ self.hash.update(cmd.hash.encode("utf-8"))
70
+ self.cmd = cmd
71
+ self._material = None
72
+
73
+ def _parse_material(self) -> None:
74
+ """
75
+ Parse the JSON string in the part command material string and
76
+ make the content accessible via material_names() and material().
77
+ """
78
+ if self._material is not None:
79
+ return
80
+ try:
81
+ if self.cmd.material_name: # type: ignore
82
+ self._material = json.loads(self.cmd.material_name) # type: ignore
83
+ for key, value in self._material.items():
84
+ value["name"] = key
85
+ else:
86
+ self._material = {}
87
+ except Exception as e:
88
+ self.session.warn(f"Unable to parse JSON material: {str(e)}")
89
+ self._material = {}
90
+
91
+ def material_names(self) -> List[str]:
92
+ """
93
+ Return the list of material names included in the part material.
94
+
95
+ Returns
96
+ -------
97
+ List[str]
98
+ The list of defined material names.
99
+ """
100
+ self._parse_material()
101
+ if self._material is None:
102
+ return []
103
+ return list(self._material.keys())
104
+
105
+ def material(self, name: str = "") -> dict:
106
+ """
107
+ Return the material dictionary for the specified material name.
108
+
109
+ Parameters
110
+ ----------
111
+ name: str
112
+ The material name to query. If no material name is given, the
113
+ first name in the material_names() list is used.
114
+
115
+ Returns
116
+ -------
117
+ dict
118
+ The material description dictionary or an empty dictionary.
119
+ """
120
+ self._parse_material()
121
+ if not name:
122
+ names = self.material_names()
123
+ if len(names):
124
+ name = names[0]
125
+ if self._material is None:
126
+ return {}
127
+ return self._material.get(name, {})
128
+
129
+ def update_geom(self, cmd: dynamic_scene_graph_pb2.UpdateGeom) -> None:
130
+ """
131
+ Merge an update geometry command into the numpy buffers being cached in this object
132
+
133
+ Parameters
134
+ ----------
135
+ cmd:
136
+ This is an array update command. It could be for coordinates, normals, variables, connectivity, etc.
137
+ """
138
+ if cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.COORDINATES:
139
+ if self.coords.size != cmd.total_array_size:
140
+ self.coords = numpy.resize(self.coords, cmd.total_array_size)
141
+ self.coords[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
142
+ elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.TRIANGLES:
143
+ if self.conn_tris.size != cmd.total_array_size:
144
+ self.conn_tris = numpy.resize(self.conn_tris, cmd.total_array_size)
145
+ self.conn_tris[cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)] = cmd.int_array
146
+ elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.LINES:
147
+ if self.conn_lines.size != cmd.total_array_size:
148
+ self.conn_lines = numpy.resize(self.conn_lines, cmd.total_array_size)
149
+ self.conn_lines[
150
+ cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)
151
+ ] = cmd.int_array
152
+ elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS) or (
153
+ cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_NORMALS
154
+ ):
155
+ self.normals_elem = cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS
156
+ if self.normals.size != cmd.total_array_size:
157
+ self.normals = numpy.resize(self.normals, cmd.total_array_size)
158
+ self.normals[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
159
+ elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE) or (
160
+ cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_VARIABLE
161
+ ):
162
+ # Get the variable definition
163
+ if cmd.variable_id in self.session.variables:
164
+ if self.cmd.color_variableid == cmd.variable_id: # type: ignore
165
+ # Receive the colorby var values
166
+ self.tcoords_elem = (
167
+ cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE
168
+ )
169
+ if self.tcoords.size != cmd.total_array_size:
170
+ self.tcoords = numpy.resize(self.tcoords, cmd.total_array_size)
171
+ self.tcoords[
172
+ cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
173
+ ] = cmd.flt_array
174
+
175
+ # Add the variable hash to the Part's hash, to pick up palette changes
176
+ var_cmd = self.session.variables.get(cmd.variable_id, None)
177
+ if var_cmd is not None:
178
+ self.hash.update(var_cmd.hash.encode("utf-8"))
179
+
180
+ if self.cmd.node_size_variableid == cmd.variable_id: # type: ignore
181
+ # Receive the node size var values
182
+ if self.node_sizes.size != cmd.total_array_size:
183
+ self.node_sizes = numpy.resize(self.node_sizes, cmd.total_array_size)
184
+ self.node_sizes[
185
+ cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
186
+ ] = cmd.flt_array
187
+ # Combine the hashes for the UpdatePart and all UpdateGeom messages
188
+ self.hash.update(cmd.hash.encode("utf-8"))
189
+
190
+ def nodal_surface_rep(self):
191
+ """
192
+ This function processes the geometry arrays and converts them into nodal representation.
193
+ It will duplicate triangles as needed (to preserve element normals) and will convert
194
+ variable data into texture coordinates.
195
+
196
+ Returns
197
+ -------
198
+ On failure, the method returns None for the first return value. The returned tuple is:
199
+
200
+ (part_command, vertices, connectivity, normals, tex_coords, var_command)
201
+
202
+ part_command: UPDATE_PART command object
203
+ vertices: numpy array of the nodal coordinates
204
+ connectivity: numpy array of the triangle indices into the vertices array
205
+ normals: numpy array of per vertex normal values (optional)
206
+ tcoords: numpy array of per vertex texture coordinates (optional)
207
+ var_command: UPDATE_VARIABLE command object for the variable the texture coordinate correspond to, if any
208
+ """
209
+ if self.cmd is None:
210
+ return None, None, None, None, None, None
211
+ if self.conn_tris.size == 0:
212
+ self.session.log(f"Note: part '{self.cmd.name}' contains no triangles.")
213
+ return None, None, None, None, None, None
214
+ verts = self.coords
215
+ _ = self._normalize_verts(verts)
216
+
217
+ conn = self.conn_tris
218
+ normals = self.normals
219
+ tcoords = None
220
+ if self.tcoords.size:
221
+ tcoords = self.tcoords
222
+ if self.tcoords_elem or self.normals_elem:
223
+ verts_per_prim = 3
224
+ num_prims = conn.size // verts_per_prim
225
+ # "flatten" the triangles to move values from elements to nodes
226
+ new_verts = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
227
+ new_conn = numpy.ndarray((num_prims * verts_per_prim,), dtype="int32")
228
+ new_tcoords = None
229
+ if tcoords is not None:
230
+ # remember that the input values are 1D at this point, we will expand to 2D later
231
+ new_tcoords = numpy.ndarray((num_prims * verts_per_prim,), dtype="float32")
232
+ new_normals = None
233
+ if normals is not None:
234
+ if normals.size == 0:
235
+ self.session.log("Warning: zero length normals!")
236
+ else:
237
+ new_normals = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
238
+ j = 0
239
+ for i0 in range(num_prims):
240
+ for i1 in range(verts_per_prim):
241
+ idx = conn[i0 * verts_per_prim + i1]
242
+ # new connectivity (identity)
243
+ new_conn[j] = j
244
+ # copy the vertex
245
+ new_verts[j * 3 + 0] = verts[idx * 3 + 0]
246
+ new_verts[j * 3 + 1] = verts[idx * 3 + 1]
247
+ new_verts[j * 3 + 2] = verts[idx * 3 + 2]
248
+ if new_normals is not None:
249
+ if self.normals_elem:
250
+ # copy the normal associated with the face
251
+ new_normals[j * 3 + 0] = normals[i0 * 3 + 0]
252
+ new_normals[j * 3 + 1] = normals[i0 * 3 + 1]
253
+ new_normals[j * 3 + 2] = normals[i0 * 3 + 2]
254
+ else:
255
+ # copy the same normal as the vertex
256
+ new_normals[j * 3 + 0] = normals[idx * 3 + 0]
257
+ new_normals[j * 3 + 1] = normals[idx * 3 + 1]
258
+ new_normals[j * 3 + 2] = normals[idx * 3 + 2]
259
+ if new_tcoords is not None:
260
+ # remember, 1D texture coords at this point
261
+ if self.tcoords_elem:
262
+ # copy the texture coord associated with the face
263
+ new_tcoords[j] = tcoords[i0]
264
+ else:
265
+ # copy the same texture coord as the vertex
266
+ new_tcoords[j] = tcoords[idx]
267
+ j += 1
268
+ # new arrays.
269
+ verts = new_verts
270
+ conn = new_conn
271
+ normals = new_normals
272
+ if tcoords is not None:
273
+ tcoords = new_tcoords
274
+
275
+ var_cmd = None
276
+ # texture coords need transformation from variable value to [ST]
277
+ if tcoords is not None:
278
+ tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3)
279
+
280
+ self.session.log(
281
+ f"Part '{self.cmd.name}' defined: {self.coords.size // 3} verts, {self.conn_tris.size // 3} tris."
282
+ )
283
+ command = self.cmd
284
+
285
+ return command, verts, conn, normals, tcoords, var_cmd
286
+
287
+ def _normalize_verts(self, verts: numpy.ndarray) -> float:
288
+ """
289
+ This function scales and translates vertices, so the longest axis in the scene is of
290
+ length 1.0, and data is centered at the origin
291
+
292
+ Returns the scale factor
293
+ """
294
+ s = 1.0
295
+ if self.session.normalize_geometry and self.session.scene_bounds is not None:
296
+ num_verts = verts.size // 3
297
+ midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5
298
+ midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5
299
+ midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5
300
+ dx = self.session.scene_bounds[3] - self.session.scene_bounds[0]
301
+ dy = self.session.scene_bounds[4] - self.session.scene_bounds[1]
302
+ dz = self.session.scene_bounds[5] - self.session.scene_bounds[2]
303
+ s = dx
304
+ if dy > s:
305
+ s = dy
306
+ if dz > s:
307
+ s = dz
308
+ if s == 0:
309
+ s = 1.0
310
+ for i in range(num_verts):
311
+ j = i * 3
312
+ verts[j + 0] = (verts[j + 0] - midx) / s
313
+ verts[j + 1] = (verts[j + 1] - midy) / s
314
+ verts[j + 2] = (verts[j + 2] - midz) / s
315
+ return 1.0 / s
316
+
317
+ def _build_st_coords(self, tcoords: numpy.ndarray, num_verts: int):
318
+ """
319
+ The Omniverse interface uses 2D texturing (s,t) to reference the texture map.
320
+ This method converts DSG texture coordinates (1D and in "variable" units) into
321
+ 2D OpenGL style [0.,1.] normalized coordinate space. the "t" coordinate will
322
+ always be 0.5.
323
+
324
+ Parameters
325
+ ----------
326
+ tcoords: numpy.ndarray
327
+ The DSG 1D texture coordinates, which are actually variable values.
328
+
329
+ num_verts: int
330
+ The number of vertices in the mesh.
331
+
332
+ Returns
333
+ -------
334
+ numpy.ndarray, Any
335
+ The ST OpenGL GL texture coordinate array and the variable definition DSG command.
336
+ """
337
+ var_dsg_id = self.cmd.color_variableid # type: ignore
338
+ var_cmd = self.session.variables[var_dsg_id]
339
+ v_min = None
340
+ v_max = None
341
+ for lvl in var_cmd.levels:
342
+ if (v_min is None) or (v_min > lvl.value):
343
+ v_min = lvl.value
344
+ if (v_max is None) or (v_max < lvl.value):
345
+ v_max = lvl.value
346
+ var_minmax: List[float] = [v_min, v_max] # type: ignore
347
+ # build a power of two x 1 texture
348
+ num_texels = len(var_cmd.texture) // 4
349
+ half_texel = 1 / (num_texels * 2.0)
350
+ tmp = numpy.ndarray((num_verts * 2,), dtype="float32")
351
+ tmp.fill(0.5) # fill in the T coordinate...
352
+ tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels
353
+ # if the range is 0, adjust the min by -1. The result is that the texture
354
+ # coords will get mapped to S=1.0 which is what EnSight does in this situation
355
+ if (var_minmax[1] - var_minmax[0]) == 0.0:
356
+ var_minmax[0] = var_minmax[0] - 1.0
357
+ var_width = var_minmax[1] - var_minmax[0]
358
+ for idx in range(num_verts):
359
+ # normalized S coord value (clamp)
360
+ s = (tcoords[idx] - var_minmax[0]) / var_width
361
+ if s < 0.0:
362
+ s = 0.0
363
+ if s > 1.0:
364
+ s = 1.0
365
+ # map to the texture range and set the S value
366
+ tmp[idx * 2] = s * tex_width + half_texel
367
+ return tmp, var_cmd
368
+
369
+ def line_rep(self):
370
+ """
371
+ This function processes the geometry arrays and returns values to represent line data.
372
+ The vertex array embeds the connectivity, so every two points represent a line segment.
373
+ The tcoords similarly follow the vertex array notion.
374
+
375
+ Returns
376
+ -------
377
+ On failure, the method returns None for the first return value. The returned tuple is:
378
+
379
+ (part_command, vertices, connectivity, tex_coords, var_command)
380
+
381
+ part_command: UPDATE_PART command object
382
+ vertices: numpy array of per-node coordinates (two per line segment)
383
+ tcoords: numpy array of per vertex texture coordinates (optional)
384
+ var_command: UPDATE_VARIABLE command object for the variable the colors correspond to, if any
385
+ """
386
+ if self.cmd is None:
387
+ return None, None, None, None
388
+ if self.cmd.render != self.cmd.CONNECTIVITY:
389
+ # Early out. Rendering type for this object is a surface rep, not a point rep
390
+ return None, None, None, None
391
+
392
+ num_lines = self.conn_lines.size // 2
393
+ if num_lines == 0:
394
+ return None, None, None, None
395
+ verts = numpy.ndarray((num_lines * 2 * 3,), dtype="float32")
396
+ tcoords = None
397
+ if self.tcoords.size:
398
+ tcoords = numpy.ndarray((num_lines * 2,), dtype="float32")
399
+ # TODO: handle elemental line values (self.tcoords_elem) by converting to nodal...
400
+ # if self.tcoords_elem:
401
+ for i in range(num_lines):
402
+ i0 = self.conn_lines[i * 2]
403
+ i1 = self.conn_lines[i * 2 + 1]
404
+ offset = i * 6
405
+ verts[offset + 0] = self.coords[i0 * 3 + 0]
406
+ verts[offset + 1] = self.coords[i0 * 3 + 1]
407
+ verts[offset + 2] = self.coords[i0 * 3 + 2]
408
+ verts[offset + 3] = self.coords[i1 * 3 + 0]
409
+ verts[offset + 4] = self.coords[i1 * 3 + 1]
410
+ verts[offset + 5] = self.coords[i1 * 3 + 2]
411
+ if tcoords is not None:
412
+ # tcoords are 1D at this point
413
+ offset = i * 2
414
+ tcoords[offset + 0] = self.tcoords[i0]
415
+ tcoords[offset + 1] = self.tcoords[i1]
416
+
417
+ _ = self._normalize_verts(verts)
418
+
419
+ var_cmd = None
420
+ # texture coords need transformation from variable value to [ST]
421
+ if tcoords is not None:
422
+ tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3)
423
+
424
+ self.session.log(f"Part '{self.cmd.name}' defined: {num_lines} lines.")
425
+ command = self.cmd
426
+
427
+ return command, verts, tcoords, var_cmd
428
+
429
+ def point_rep(self):
430
+ """
431
+ This function processes the geometry arrays and returns values to represent point data
432
+
433
+ Returns
434
+ -------
435
+ On failure, the method returns None for the first return value. The returned tuple is:
436
+
437
+ (part_command, vertices, sizes, colors, var_command)
438
+
439
+ part_command: UPDATE_PART command object
440
+ vertices: numpy array of per-node coordinates
441
+ sizes: numpy array of per-node radii
442
+ colors: numpy array of per-node rgb colors
443
+ var_command: UPDATE_VARIABLE command object for the variable the colors correspond to, if any
444
+ """
445
+ if self.cmd is None:
446
+ return None, None, None, None, None
447
+ if self.cmd.render != self.cmd.NODES:
448
+ # Early out. Rendering type for this object is a surface rep, not a point rep
449
+ return None, None, None, None, None
450
+ verts = self.coords
451
+ num_verts = verts.size // 3
452
+ norm_scale = self._normalize_verts(verts)
453
+
454
+ # Convert var values in self.tcoords to RGB colors
455
+ # For now, look up RGB colors. Planned USD enhancements should allow tex coords instead.
456
+ colors = None
457
+ var_cmd = None
458
+
459
+ if self.tcoords.size and self.tcoords.size == num_verts:
460
+ var_dsg_id = self.cmd.color_variableid
461
+ var_cmd = self.session.variables[var_dsg_id]
462
+ if len(var_cmd.levels) == 0:
463
+ self.session.log(
464
+ f"Note: Node rep not created for part '{self.cmd.name}'. It has var values, but a palette with 0 levels."
465
+ )
466
+ return None, None, None, None, None
467
+
468
+ p_min = None
469
+ p_max = None
470
+ for lvl in var_cmd.levels:
471
+ if (p_min is None) or (p_min > lvl.value):
472
+ p_min = lvl.value
473
+ if (p_max is None) or (p_max < lvl.value):
474
+ p_max = lvl.value
475
+
476
+ num_texels = int(len(var_cmd.texture) / 4)
477
+
478
+ colors = numpy.ndarray((num_verts * 3,), dtype="float32")
479
+ low_color = [c / 255.0 for c in var_cmd.texture[0:3]]
480
+ high_color = [
481
+ c / 255.0 for c in var_cmd.texture[4 * (num_texels - 1) : 4 * (num_texels - 1) + 3]
482
+ ]
483
+ if p_min == p_max:
484
+ # Special case where palette min == palette max
485
+ mid_color = var_cmd[4 * (num_texels // 2) : 4 * (num_texels // 2) + 3]
486
+ for idx in range(num_verts):
487
+ val = self.tcoords[idx]
488
+ if val == p_min:
489
+ colors[idx * 3 : idx * 3 + 3] = mid_color
490
+ elif val < p_min:
491
+ colors[idx * 3 : idx * 3 + 3] = low_color
492
+ elif val > p_min:
493
+ colors[idx * 3 : idx * 3 + 3] = high_color
494
+ else:
495
+ for idx in range(num_verts):
496
+ val = self.tcoords[idx]
497
+ if val <= p_min:
498
+ colors[idx * 3 : idx * 3 + 3] = low_color
499
+ else:
500
+ pal_pos = (num_texels - 1) * (val - p_min) / (p_max - p_min)
501
+ pal_idx, pal_sub = divmod(pal_pos, 1)
502
+ pal_idx = int(pal_idx)
503
+
504
+ if pal_idx >= num_texels - 1:
505
+ colors[idx * 3 : idx * 3 + 3] = high_color
506
+ else:
507
+ col0 = var_cmd.texture[pal_idx * 4 : pal_idx * 4 + 3]
508
+ col1 = var_cmd.texture[4 + pal_idx * 4 : 4 + pal_idx * 4 + 3]
509
+ for ii in range(0, 3):
510
+ colors[idx * 3 + ii] = (
511
+ col0[ii] * pal_sub + col1[ii] * (1.0 - pal_sub)
512
+ ) / 255.0
513
+ self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.")
514
+
515
+ node_sizes = None
516
+ if self.node_sizes.size and self.node_sizes.size == num_verts:
517
+ # Pass out the node sizes if there is a size-by variable
518
+ node_size_default = self.cmd.node_size_default * norm_scale
519
+ node_sizes = numpy.ndarray((num_verts,), dtype="float32")
520
+ for ii in range(0, num_verts):
521
+ node_sizes[ii] = self.node_sizes[ii] * node_size_default
522
+ elif norm_scale != 1.0:
523
+ # Pass out the node sizes if the model is normalized to fit in a unit cube
524
+ node_size_default = self.cmd.node_size_default * norm_scale
525
+ node_sizes = numpy.ndarray((num_verts,), dtype="float32")
526
+ for ii in range(0, num_verts):
527
+ node_sizes[ii] = node_size_default
528
+
529
+ self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.")
530
+ command = self.cmd
531
+
532
+ return command, verts, node_sizes, colors, var_cmd
533
+
534
+
535
+ class UpdateHandler(object):
536
+ """
537
+ This class serves as the interface between a DSGSession and a hosting application.
538
+ The DSGSession processes the general aspects of the gRPC pipeline and collects the
539
+ various DSG objects into collections of: groups, variables, etc. It also coalesces
540
+ the individual array updates into a "Part" object which represents a single addressable
541
+ mesh chunk.
542
+ UpdateHandler methods are called as the various update happen, and it is called when
543
+ a mesh chunk has been entirely defined. In most scenarios, a subclass of UpdateHandler
544
+ is passed to the DSGSession to handshake the mesh data to the application target.
545
+ """
546
+
547
+ def __init__(self) -> None:
548
+ self._session: "DSGSession"
549
+
550
+ @property
551
+ def session(self) -> "DSGSession":
552
+ """The session object this handler has been associated with"""
553
+ return self._session
554
+
555
+ @session.setter
556
+ def session(self, session: "DSGSession") -> None:
557
+ self._session = session
558
+
559
+ def add_group(self, id: int, view: bool = False) -> None:
560
+ """Called when a new group command has been added: self.session.groups[id]"""
561
+ if view:
562
+ self.session.log(f"Adding view: {self.session.groups[id]}")
563
+ else:
564
+ self.session.log(f"Adding group: {self.session.groups[id].name}")
565
+
566
+ def add_variable(self, id: int) -> None:
567
+ """Called when a new group command has been added: self.session.variables[id]"""
568
+ self.session.log(f"Adding variable: {self.session.variables[id].name}")
569
+
570
+ def finalize_part(self, part: Part) -> None:
571
+ """Called when all the updates on a Part object have been completed.
572
+
573
+ Note: this superclass method should be called after the subclass has processed
574
+ the part geometry as the saved part command will be destroyed by this call.
575
+ """
576
+ if part.cmd:
577
+ self.session.log(f"Part finalized: {part.cmd.name}")
578
+ part.cmd = None
579
+
580
+ def start_connection(self) -> None:
581
+ """A new gRPC connection has been established: self.session.grpc"""
582
+ grpc = self.session.grpc
583
+ self.session.log(f"gRPC connection established to: {grpc.host}:{grpc.port}")
584
+
585
+ def end_connection(self) -> None:
586
+ """The previous gRPC connection has been closed"""
587
+ self.session.log("gRPC connection closed")
588
+
589
+ def begin_update(self) -> None:
590
+ """A new scene update is about to begin"""
591
+ self.session.log("Begin update ------------------------")
592
+
593
+ def end_update(self) -> None:
594
+ """The scene update is complete"""
595
+ self.session.log("End update ------------------------")
596
+
597
+ def get_dsg_cmd_attribute(self, obj: Any, name: str, default: Any = None) -> Optional[str]:
598
+ """Utility function to get an attribute from a DSG update object
599
+
600
+ Note: UpdateVariable and UpdateGroup commands support generic attributes
601
+ """
602
+ return obj.attributes.get(name, default)
603
+
604
+ def group_matrix(self, group: Any) -> Any:
605
+ matrix = group.matrix4x4
606
+ # The Case matrix is basically the camera transform. In vrmode, we only want
607
+ # the raw geometry, so use the identity matrix.
608
+ if (
609
+ self.get_dsg_cmd_attribute(group, "ENS_OBJ_TYPE") == "ENS_CASE"
610
+ ) and self.session.vrmode:
611
+ matrix = [
612
+ 1.0,
613
+ 0.0,
614
+ 0.0,
615
+ 0.0,
616
+ 0.0,
617
+ 1.0,
618
+ 0.0,
619
+ 0.0,
620
+ 0.0,
621
+ 0.0,
622
+ 1.0,
623
+ 0.0,
624
+ 0.0,
625
+ 0.0,
626
+ 0.0,
627
+ 1.0,
628
+ ]
629
+ return matrix
630
+
631
+
632
+ class DSGSession(object):
633
+ def __init__(
634
+ self,
635
+ port: int = 12345,
636
+ host: str = "127.0.0.1",
637
+ security_code: str = "",
638
+ verbose: int = 0,
639
+ normalize_geometry: bool = False,
640
+ vrmode: bool = False,
641
+ time_scale: float = 1.0,
642
+ handler: UpdateHandler = UpdateHandler(),
643
+ ):
644
+ """
645
+ Manage a gRPC connection and link it to an UpdateHandler instance
646
+
647
+ This class makes a DSG gRPC connection via the specified port and host (leveraging
648
+ the passed security code). As DSG protobuffers arrive, they are merged into Part
649
+ object instances and the UpdateHandler is invoked to further process them.
650
+
651
+ Parameters
652
+ ----------
653
+ port : int
654
+ The port number the EnSight gRPC service is running on.
655
+ The default is ``12345``.
656
+ host : str
657
+ Name of the host that the EnSight gRPC service is running on.
658
+ The default is ``"127.0.0.1"``, which is the localhost.
659
+ security_code : str
660
+ Shared security code for validating the gRPC communication.
661
+ The default is ``""``.
662
+ verbose : int
663
+ The verbosity level. If set to 1 or higher the class will call logging.info
664
+ for log output. The default is ``0``.
665
+ normalize_geometry : bool
666
+ If True, the scene coordinates will be remapped into the volume [-1,-1,-1] - [1,1,1]
667
+ The default is not to remap coordinates.
668
+ vrmode : bool
669
+ If True, do not include the EnSight camera in the generated view group. The default
670
+ is to include the EnSight view in the scene transformations.
671
+ time_scale : float
672
+ All DSG protobuffers time values will be multiplied by this factor after
673
+ being received. The default is ``1.0``.
674
+ handler : UpdateHandler
675
+ This is an UpdateHandler subclass that is called back when the state of
676
+ a scene transfer changes. For example, methods are called when the
677
+ transfer begins or ends and when a Part (mesh block) is ready for processing.
678
+ """
679
+ super().__init__()
680
+ self._grpc = ensight_grpc.EnSightGRPC(port=port, host=host, secret_key=security_code)
681
+ self._callback_handler = handler
682
+ self._verbose = verbose
683
+ self._thread: Optional[threading.Thread] = None
684
+ self._message_queue: queue.Queue = queue.Queue() # Messages coming from EnSight
685
+ self._dsg_queue: Optional[queue.SimpleQueue] = None # Outgoing messages to EnSight
686
+ self._shutdown = False
687
+ self._dsg = None
688
+ self._normalize_geometry = normalize_geometry
689
+ self._vrmode = vrmode
690
+ self._time_scale = time_scale
691
+ self._time_limits = [
692
+ sys.float_info.max,
693
+ -sys.float_info.max,
694
+ ] # Min/max across all time steps
695
+ self._mesh_block_count = 0
696
+ self._variables: Dict[int, Any] = dict()
697
+ self._groups: Dict[int, Any] = dict()
698
+ self._part: Part = Part(self)
699
+ self._scene_bounds: Optional[List] = None
700
+ self._cur_timeline: List = [0.0, 0.0] # Start/End time for current update
701
+ self._callback_handler.session = self
702
+ # log any status changes to this file. external apps will be monitoring
703
+ self._status_file = os.environ.get("ANSYS_OV_SERVER_STATUS_FILENAME", "")
704
+ self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0)
705
+
706
+ @property
707
+ def scene_bounds(self) -> Optional[List]:
708
+ return self._scene_bounds
709
+
710
+ @property
711
+ def mesh_block_count(self) -> int:
712
+ return self._mesh_block_count
713
+
714
+ @property
715
+ def vrmode(self) -> bool:
716
+ return self._vrmode
717
+
718
+ @vrmode.setter
719
+ def vrmode(self, value: bool) -> None:
720
+ self._vrmode = value
721
+
722
+ @property
723
+ def normalize_geometry(self) -> bool:
724
+ return self._normalize_geometry
725
+
726
+ @normalize_geometry.setter
727
+ def normalize_geometry(self, value: bool) -> None:
728
+ self._normalize_geometry = value
729
+
730
+ @property
731
+ def variables(self) -> dict:
732
+ return self._variables
733
+
734
+ @property
735
+ def groups(self) -> dict:
736
+ return self._groups
737
+
738
+ @property
739
+ def part(self) -> Part:
740
+ return self._part
741
+
742
+ @property
743
+ def time_limits(self) -> List:
744
+ return self._time_limits
745
+
746
+ @property
747
+ def cur_timeline(self) -> List:
748
+ return self._cur_timeline
749
+
750
+ @cur_timeline.setter
751
+ def cur_timeline(self, timeline: List) -> None:
752
+ self._cur_timeline = timeline
753
+ self._time_limits[0] = min(self._time_limits[0], self._cur_timeline[0])
754
+ self._time_limits[1] = max(self._time_limits[1], self._cur_timeline[1])
755
+
756
+ @property
757
+ def grpc(self) -> ensight_grpc.EnSightGRPC:
758
+ return self._grpc
759
+
760
+ def log(self, s: str, level: int = 0) -> None:
761
+ """Log a string to the logging system
762
+
763
+ If the message level is less than the current verbosity,
764
+ emit the message.
765
+ """
766
+ if level < self._verbose:
767
+ logging.info(s)
768
+
769
+ @staticmethod
770
+ def warn(s: str) -> None:
771
+ """Issue a warning to the logging system
772
+
773
+ The logging message is mapped to "warn" and cannot be blocked via verbosity
774
+ checks.
775
+ """
776
+ logging.warning(s)
777
+
778
+ @staticmethod
779
+ def error(s: str) -> None:
780
+ """Issue an error to the logging system
781
+
782
+ The logging message is mapped to "error" and cannot be blocked via verbosity
783
+ checks.
784
+ """
785
+ logging.error(s)
786
+
787
+ def start(self) -> int:
788
+ """Start a gRPC connection to an EnSight instance
789
+
790
+ Make a gRPC connection and start a DSG stream handler.
791
+
792
+ Returns
793
+ -------
794
+ 0 on success, -1 on an error.
795
+ """
796
+ # Start by setting up and verifying the connection
797
+ self._grpc.connect()
798
+ if not self._grpc.is_connected():
799
+ self.log(f"Unable to establish gRPC connection to: {self._grpc.host}:{self._grpc.port}")
800
+ return -1
801
+ # Streaming API requires an iterator, so we make one from a queue
802
+ # it also returns an iterator. self._dsg_queue is the input stream interface
803
+ # self._dsg is the returned stream iterator.
804
+ if self._dsg is not None:
805
+ return 0
806
+ self._dsg_queue = queue.SimpleQueue()
807
+ self._dsg = self._grpc.dynamic_scene_graph_stream(
808
+ iter(self._dsg_queue.get, None) # type:ignore
809
+ )
810
+ self._thread = threading.Thread(target=self._poll_messages)
811
+ if self._thread is not None:
812
+ self._thread.start()
813
+ self._callback_handler.start_connection()
814
+ return 0
815
+
816
+ def end(self):
817
+ """Stop a gRPC connection to the EnSight instance"""
818
+ self._callback_handler.end_connection()
819
+ self._grpc.shutdown()
820
+ self._shutdown = True
821
+ self._thread.join()
822
+ self._grpc.shutdown()
823
+ self._dsg = None
824
+ self._thread = None
825
+ self._dsg_queue = None
826
+
827
+ def is_shutdown(self):
828
+ """Check the service shutdown request status"""
829
+ return self._shutdown
830
+
831
+ def _update_status_file(self, timed: bool = False):
832
+ """
833
+ Update the status file contents. The status file will contain the
834
+ following json object, stored as: self._status
835
+
836
+ {
837
+ 'status' : "working|idle",
838
+ 'start_time' : timestamp_of_update_begin,
839
+ 'processed_buffers' : number_of_protobuffers_processed,
840
+ 'total_buffers' : number_of_protobuffers_total,
841
+ }
842
+
843
+ Parameters
844
+ ----------
845
+ timed : bool, optional:
846
+ if True, only update every second.
847
+
848
+ """
849
+ if self._status_file:
850
+ current_time = time.time()
851
+ if timed:
852
+ last_time = self._status.get("last_time", 0.0)
853
+ if current_time - last_time < 1.0: # type: ignore
854
+ return
855
+ self._status["last_time"] = current_time
856
+ try:
857
+ message = json.dumps(self._status)
858
+ with open(self._status_file, "w") as status_file:
859
+ status_file.write(message)
860
+ except IOError:
861
+ pass # Note failure is expected here in some cases
862
+
863
+ def request_an_update(self, animation: bool = False, allow_spontaneous: bool = True) -> None:
864
+ """Start a DSG update
865
+ Send a command to the DSG protocol to "init" an update.
866
+
867
+ Parameters
868
+ ----------
869
+ animation:
870
+ if True, export all EnSight timesteps.
871
+ allow_spontaneous:
872
+ if True, allow EnSight to trigger async updates.
873
+ """
874
+ # Send an INIT command to trigger a stream of update packets
875
+ cmd = dynamic_scene_graph_pb2.SceneClientCommand()
876
+ cmd.command_type = dynamic_scene_graph_pb2.SceneClientCommand.INIT
877
+ # Allow EnSight push commands, but full scene only for now...
878
+ cmd.init.allow_spontaneous = allow_spontaneous
879
+ cmd.init.include_temporal_geometry = animation
880
+ cmd.init.allow_incremental_updates = False
881
+ cmd.init.maximum_chunk_size = 1024 * 1024
882
+ self._dsg_queue.put(cmd) # type:ignore
883
+
884
+ def _poll_messages(self) -> None:
885
+ """Core interface to grab DSG events from gRPC and queue them for processing
886
+
887
+ This is run by a thread that is monitoring the dsg RPC call for update messages
888
+ it places them in _message_queue as it finds them. They are picked up by the
889
+ main thread via get_next_message()
890
+ """
891
+ while not self._shutdown:
892
+ try:
893
+ self._message_queue.put(next(self._dsg)) # type:ignore
894
+ except Exception:
895
+ self._shutdown = True
896
+ self.log("DSG connection broken, calling exit")
897
+ os._exit(0)
898
+
899
+ def _get_next_message(self, wait: bool = True) -> Any:
900
+ """Get the next queued up protobuffer message
901
+
902
+ Called by the main thread to get any messages that were pulled in from the
903
+ dsg stream and placed here by _poll_messages()
904
+ """
905
+ try:
906
+ return self._message_queue.get(block=wait)
907
+ except queue.Empty:
908
+ return None
909
+
910
+ def _reset(self):
911
+ self._variables = {}
912
+ self._groups = {}
913
+ self._part = Part(self)
914
+ self._scene_bounds = None
915
+ self._mesh_block_count = 0 # reset when a new group shows up
916
+
917
+ def handle_one_update(self) -> None:
918
+ """Monitor the DSG stream and handle a single update operation
919
+
920
+ Wait until we get the scene update begin message. From there, reset the current
921
+ scene buckets and then parse all the incoming commands until we get the scene
922
+ update end command. At which point, save the generated stage (started in the
923
+ view command handler). Note: Parts are handled with an available bucket at all times.
924
+ When a new part update comes in or the scene update end happens, the part is "finished".
925
+ """
926
+ # An update starts with a UPDATE_SCENE_BEGIN command
927
+ cmd = self._get_next_message()
928
+ while (cmd is not None) and (
929
+ cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_BEGIN
930
+ ):
931
+ # Look for a begin command
932
+ cmd = self._get_next_message()
933
+
934
+ # Start anew
935
+ self._reset()
936
+ self._callback_handler.begin_update()
937
+
938
+ # Update our status
939
+ self._status = dict(
940
+ status="working", start_time=time.time(), processed_buffers=1, total_buffers=1
941
+ )
942
+ self._update_status_file()
943
+
944
+ # handle the various commands until UPDATE_SCENE_END
945
+ cmd = self._get_next_message()
946
+ while (cmd is not None) and (
947
+ cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_END
948
+ ):
949
+ self._handle_update_command(cmd)
950
+ self._status["processed_buffers"] += 1 # type: ignore
951
+ self._status["total_buffers"] = self._status["processed_buffers"] + self._message_queue.qsize() # type: ignore
952
+ self._update_status_file(timed=True)
953
+ cmd = self._get_next_message()
954
+
955
+ # Flush the last part
956
+ self._finish_part()
957
+
958
+ self._callback_handler.end_update()
959
+
960
+ # Update our status
961
+ self._status = dict(status="idle", start_time=0.0, processed_buffers=0, total_buffers=0)
962
+ self._update_status_file()
963
+
964
+ def _handle_update_command(self, cmd: dynamic_scene_graph_pb2.SceneUpdateCommand) -> None:
965
+ """Dispatch out a scene update command to the proper handler
966
+
967
+ Given a command object, pull out the correct portion of the protobuffer union and
968
+ pass it to the appropriate handler.
969
+
970
+ Parameters
971
+ ----------
972
+ cmd:
973
+ The command to be dispatched.
974
+ """
975
+ name = "Unknown"
976
+ if cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.DELETE_ID:
977
+ name = "Delete IDs"
978
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_PART:
979
+ name = "Part update"
980
+ tmp = cmd.update_part
981
+ self._handle_part(tmp)
982
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GROUP:
983
+ name = "Group update"
984
+ tmp = cmd.update_group
985
+ self._handle_group(tmp)
986
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GEOM:
987
+ name = "Geom update"
988
+ tmp = cmd.update_geom
989
+ self._part.update_geom(tmp)
990
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VARIABLE:
991
+ name = "Variable update"
992
+ tmp = cmd.update_variable
993
+ self._handle_variable(tmp)
994
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VIEW:
995
+ name = "View update"
996
+ tmp = cmd.update_view
997
+ self._handle_view(tmp)
998
+ elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_TEXTURE:
999
+ name = "Texture update"
1000
+ self.log(f"{name} --------------------------")
1001
+
1002
+ def _finish_part(self) -> None:
1003
+ """Complete the current part
1004
+
1005
+ There is always a part being modified. This method completes the current part, committing
1006
+ it to the handler.
1007
+ """
1008
+ try:
1009
+ self._callback_handler.finalize_part(self.part)
1010
+ except Exception as e:
1011
+ import traceback
1012
+
1013
+ self.warn(f"Error encountered while finalizing part geometry: {str(e)}")
1014
+ traceback_str = "".join(traceback.format_tb(e.__traceback__))
1015
+ logging.debug(f"Traceback: {traceback_str}")
1016
+ self._mesh_block_count += 1
1017
+
1018
+ def _handle_part(self, part_cmd: Any) -> None:
1019
+ """Handle a DSG UPDATE_PART command
1020
+
1021
+ Finish the current part and set up the next part.
1022
+
1023
+ Parameters
1024
+ ----------
1025
+ part:
1026
+ The command coming from the EnSight stream.
1027
+ """
1028
+ self._finish_part()
1029
+ self._part.reset(part_cmd)
1030
+
1031
+ def _handle_group(self, group: Any) -> None:
1032
+ """Handle a DSG UPDATE_GROUP command
1033
+
1034
+ Parameters
1035
+ ----------
1036
+ group:
1037
+ The command coming from the EnSight stream.
1038
+ """
1039
+ # reset current mesh (part) count for unique "part" naming in USD
1040
+ self._mesh_block_count = 0
1041
+
1042
+ # record the scene bounds in case they are needed later
1043
+ self._groups[group.id] = group
1044
+ bounds = group.attributes.get("ENS_SCENE_BOUNDS", None)
1045
+ if bounds:
1046
+ minmax = list()
1047
+ for v in bounds.split(","):
1048
+ try:
1049
+ minmax.append(float(v))
1050
+ except ValueError:
1051
+ pass
1052
+ if len(minmax) == 6:
1053
+ self._scene_bounds = minmax
1054
+ # callback
1055
+ self._callback_handler.add_group(group.id)
1056
+
1057
+ def _handle_variable(self, var: Any) -> None:
1058
+ """Handle a DSG UPDATE_VARIABLE command
1059
+
1060
+ Save off the EnSight variable DSG command object.
1061
+
1062
+ Parameters
1063
+ ----------
1064
+ var:
1065
+ The command coming from the EnSight stream.
1066
+ """
1067
+ self._variables[var.id] = var
1068
+ self._callback_handler.add_variable(var.id)
1069
+
1070
+ def _handle_view(self, view: Any) -> None:
1071
+ """Handle a DSG UPDATE_VIEW command
1072
+
1073
+ Parameters
1074
+ ----------
1075
+ view:
1076
+ The command coming from the EnSight stream.
1077
+ """
1078
+ self._finish_part()
1079
+ self._scene_bounds = None
1080
+ self._groups[view.id] = view
1081
+ if len(view.timeline) == 2:
1082
+ view.timeline[0] *= self._time_scale
1083
+ view.timeline[1] *= self._time_scale
1084
+ self.cur_timeline = [view.timeline[0], view.timeline[1]]
1085
+ self._callback_handler.add_group(view.id, view=True)