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