ansys-pyensight-core 0.8.1__py3-none-any.whl → 0.8.3__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.
- ansys/pyensight/core/renderable.py +14 -3
- ansys/pyensight/core/utils/dsg_server.py +698 -0
- ansys/pyensight/core/utils/omniverse_dsg_server.py +163 -692
- ansys/pyensight/core/utils/readers.py +1 -1
- {ansys_pyensight_core-0.8.1.dist-info → ansys_pyensight_core-0.8.3.dist-info}/METADATA +5 -5
- {ansys_pyensight_core-0.8.1.dist-info → ansys_pyensight_core-0.8.3.dist-info}/RECORD +8 -7
- {ansys_pyensight_core-0.8.1.dist-info → ansys_pyensight_core-0.8.3.dist-info}/LICENSE +0 -0
- {ansys_pyensight_core-0.8.1.dist-info → ansys_pyensight_core-0.8.3.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ansys.api.pyensight.v0 import dynamic_scene_graph_pb2
|
|
8
|
+
from ansys.pyensight.core import ensight_grpc
|
|
9
|
+
import numpy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Part(object):
|
|
13
|
+
def __init__(self, session: "DSGSession"):
|
|
14
|
+
"""
|
|
15
|
+
This object roughly represents an EnSight "Part". It contains the connectivity,
|
|
16
|
+
coordinates, normals and texture coordinate information for one DSG entity
|
|
17
|
+
|
|
18
|
+
This object stores basic geometry information coming from the DSG protocol. The
|
|
19
|
+
update_geom() method can parse an "UpdateGeom" protobuffer and merges the results
|
|
20
|
+
into the Part object.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
session:
|
|
25
|
+
The DSG connection session object.
|
|
26
|
+
"""
|
|
27
|
+
self.session = session
|
|
28
|
+
self.conn_tris = numpy.array([], dtype="int32")
|
|
29
|
+
self.conn_lines = numpy.array([], dtype="int32")
|
|
30
|
+
self.coords = numpy.array([], dtype="float32")
|
|
31
|
+
self.normals = numpy.array([], dtype="float32")
|
|
32
|
+
self.normals_elem = False
|
|
33
|
+
self.tcoords = numpy.array([], dtype="float32")
|
|
34
|
+
self.tcoords_var_id: Optional[int] = None
|
|
35
|
+
self.tcoords_elem = False
|
|
36
|
+
self.cmd: Optional[Any] = None
|
|
37
|
+
self.reset()
|
|
38
|
+
|
|
39
|
+
def reset(self, cmd: Any = None) -> None:
|
|
40
|
+
self.conn_tris = numpy.array([], dtype="int32")
|
|
41
|
+
self.conn_lines = numpy.array([], dtype="int32")
|
|
42
|
+
self.coords = numpy.array([], dtype="float32")
|
|
43
|
+
self.normals = numpy.array([], dtype="float32")
|
|
44
|
+
self.normals_elem = False
|
|
45
|
+
self.tcoords = numpy.array([], dtype="float32")
|
|
46
|
+
self.tcoords_var_id = None
|
|
47
|
+
self.tcoords_elem = False
|
|
48
|
+
self.cmd = cmd
|
|
49
|
+
|
|
50
|
+
def update_geom(self, cmd: dynamic_scene_graph_pb2.UpdateGeom) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Merge an update geometry command into the numpy buffers being cached in this object
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
cmd:
|
|
57
|
+
This is an array update command. It could be for coordinates, normals, variables, connectivity, etc.
|
|
58
|
+
"""
|
|
59
|
+
if cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.COORDINATES:
|
|
60
|
+
if self.coords.size != cmd.total_array_size:
|
|
61
|
+
self.coords = numpy.resize(self.coords, cmd.total_array_size)
|
|
62
|
+
self.coords[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
|
|
63
|
+
elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.TRIANGLES:
|
|
64
|
+
if self.conn_tris.size != cmd.total_array_size:
|
|
65
|
+
self.conn_tris = numpy.resize(self.conn_tris, cmd.total_array_size)
|
|
66
|
+
self.conn_tris[cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)] = cmd.int_array
|
|
67
|
+
elif cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.LINES:
|
|
68
|
+
if self.conn_lines.size != cmd.total_array_size:
|
|
69
|
+
self.conn_lines = numpy.resize(self.conn_lines, cmd.total_array_size)
|
|
70
|
+
self.conn_lines[
|
|
71
|
+
cmd.chunk_offset : cmd.chunk_offset + len(cmd.int_array)
|
|
72
|
+
] = cmd.int_array
|
|
73
|
+
elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS) or (
|
|
74
|
+
cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_NORMALS
|
|
75
|
+
):
|
|
76
|
+
self.normals_elem = cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_NORMALS
|
|
77
|
+
if self.normals.size != cmd.total_array_size:
|
|
78
|
+
self.normals = numpy.resize(self.normals, cmd.total_array_size)
|
|
79
|
+
self.normals[cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)] = cmd.flt_array
|
|
80
|
+
elif (cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE) or (
|
|
81
|
+
cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.NODE_VARIABLE
|
|
82
|
+
):
|
|
83
|
+
# Get the variable definition
|
|
84
|
+
if cmd.variable_id in self.session.variables:
|
|
85
|
+
self.tcoords_var_id = cmd.variable_id
|
|
86
|
+
self.tcoords_elem = (
|
|
87
|
+
cmd.payload_type == dynamic_scene_graph_pb2.UpdateGeom.ELEM_VARIABLE
|
|
88
|
+
)
|
|
89
|
+
if self.tcoords.size != cmd.total_array_size:
|
|
90
|
+
self.tcoords = numpy.resize(self.tcoords, cmd.total_array_size)
|
|
91
|
+
self.tcoords[
|
|
92
|
+
cmd.chunk_offset : cmd.chunk_offset + len(cmd.flt_array)
|
|
93
|
+
] = cmd.flt_array
|
|
94
|
+
else:
|
|
95
|
+
self.tcoords_var_id = None
|
|
96
|
+
|
|
97
|
+
def build(self):
|
|
98
|
+
"""
|
|
99
|
+
This function processes the geometry arrays and converts them into nodal representation.
|
|
100
|
+
It will duplicate triangles as needed (to preserve element normals) and will convert
|
|
101
|
+
variable data into texture coordinates.
|
|
102
|
+
|
|
103
|
+
Note: this call can only be made once as the internal structures are released when this
|
|
104
|
+
call is made.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
On failure, the method returns None for the first return value. The returned tuple is:
|
|
109
|
+
|
|
110
|
+
(part_command, vertices, connectivity, normals, tex_coords, var_command)
|
|
111
|
+
|
|
112
|
+
part_command: UPDATE_PART command object
|
|
113
|
+
vertices: numpy array of the nodal coordinates
|
|
114
|
+
connectivity: numpy array of the triangle indices into the vertices array
|
|
115
|
+
normals: numpy array of per vertex normal values (optional)
|
|
116
|
+
tcoords: numpy array of per vertex texture coordinates (optional)
|
|
117
|
+
var_command: UPDATE_VARIABLE command object for the variable the texture coordinate correspond to, if any
|
|
118
|
+
"""
|
|
119
|
+
if self.cmd is None:
|
|
120
|
+
return None, None, None, None, None, None
|
|
121
|
+
if self.conn_lines.size:
|
|
122
|
+
self.session.log(
|
|
123
|
+
f"Note: part '{self.cmd.name}' contains lines which are not currently supported."
|
|
124
|
+
)
|
|
125
|
+
self.cmd = None
|
|
126
|
+
return None, None, None, None, None, None
|
|
127
|
+
verts = self.coords
|
|
128
|
+
if self.session.normalize_geometry and self.session.scene_bounds is not None:
|
|
129
|
+
midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5
|
|
130
|
+
midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5
|
|
131
|
+
midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5
|
|
132
|
+
dx = self.session.scene_bounds[3] - self.session.scene_bounds[0]
|
|
133
|
+
dy = self.session.scene_bounds[4] - self.session.scene_bounds[1]
|
|
134
|
+
dz = self.session.scene_bounds[5] - self.session.scene_bounds[2]
|
|
135
|
+
s = dx
|
|
136
|
+
if dy > s:
|
|
137
|
+
s = dy
|
|
138
|
+
if dz > s:
|
|
139
|
+
s = dz
|
|
140
|
+
if s == 0:
|
|
141
|
+
s = 1.0
|
|
142
|
+
num_verts = int(verts.size / 3)
|
|
143
|
+
for i in range(num_verts):
|
|
144
|
+
j = i * 3
|
|
145
|
+
verts[j + 0] = (verts[j + 0] - midx) / s
|
|
146
|
+
verts[j + 1] = (verts[j + 1] - midy) / s
|
|
147
|
+
verts[j + 2] = (verts[j + 2] - midz) / s
|
|
148
|
+
|
|
149
|
+
conn = self.conn_tris
|
|
150
|
+
normals = self.normals
|
|
151
|
+
tcoords = None
|
|
152
|
+
if self.tcoords.size:
|
|
153
|
+
tcoords = self.tcoords
|
|
154
|
+
if self.tcoords_elem or self.normals_elem:
|
|
155
|
+
verts_per_prim = 3
|
|
156
|
+
num_prims = int(conn.size / verts_per_prim)
|
|
157
|
+
# "flatten" the triangles to move values from elements to nodes
|
|
158
|
+
new_verts = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
|
|
159
|
+
new_conn = numpy.ndarray((num_prims * verts_per_prim,), dtype="int32")
|
|
160
|
+
new_tcoords = None
|
|
161
|
+
if tcoords is not None:
|
|
162
|
+
# remember that the input values are 1D at this point, we will expand to 2D later
|
|
163
|
+
new_tcoords = numpy.ndarray((num_prims * verts_per_prim,), dtype="float32")
|
|
164
|
+
new_normals = None
|
|
165
|
+
if normals is not None:
|
|
166
|
+
if normals.size == 0:
|
|
167
|
+
self.session.log("Warning: zero length normals!")
|
|
168
|
+
else:
|
|
169
|
+
new_normals = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32")
|
|
170
|
+
j = 0
|
|
171
|
+
for i0 in range(num_prims):
|
|
172
|
+
for i1 in range(verts_per_prim):
|
|
173
|
+
idx = conn[i0 * verts_per_prim + i1]
|
|
174
|
+
# new connectivity (identity)
|
|
175
|
+
new_conn[j] = j
|
|
176
|
+
# copy the vertex
|
|
177
|
+
new_verts[j * 3 + 0] = verts[idx * 3 + 0]
|
|
178
|
+
new_verts[j * 3 + 1] = verts[idx * 3 + 1]
|
|
179
|
+
new_verts[j * 3 + 2] = verts[idx * 3 + 2]
|
|
180
|
+
if new_normals is not None:
|
|
181
|
+
if self.normals_elem:
|
|
182
|
+
# copy the normal associated with the face
|
|
183
|
+
new_normals[j * 3 + 0] = normals[i0 * 3 + 0]
|
|
184
|
+
new_normals[j * 3 + 1] = normals[i0 * 3 + 1]
|
|
185
|
+
new_normals[j * 3 + 2] = normals[i0 * 3 + 2]
|
|
186
|
+
else:
|
|
187
|
+
# copy the same normal as the vertex
|
|
188
|
+
new_normals[j * 3 + 0] = normals[idx * 3 + 0]
|
|
189
|
+
new_normals[j * 3 + 1] = normals[idx * 3 + 1]
|
|
190
|
+
new_normals[j * 3 + 2] = normals[idx * 3 + 2]
|
|
191
|
+
if new_tcoords is not None:
|
|
192
|
+
# remember, 1D texture coords at this point
|
|
193
|
+
if self.tcoords_elem:
|
|
194
|
+
# copy the texture coord associated with the face
|
|
195
|
+
new_tcoords[j] = tcoords[i0]
|
|
196
|
+
else:
|
|
197
|
+
# copy the same texture coord as the vertex
|
|
198
|
+
new_tcoords[j] = tcoords[idx]
|
|
199
|
+
j += 1
|
|
200
|
+
# new arrays.
|
|
201
|
+
verts = new_verts
|
|
202
|
+
conn = new_conn
|
|
203
|
+
normals = new_normals
|
|
204
|
+
if tcoords is not None:
|
|
205
|
+
tcoords = new_tcoords
|
|
206
|
+
|
|
207
|
+
var_cmd = None
|
|
208
|
+
# texture coords need transformation from variable value to [ST]
|
|
209
|
+
if tcoords is not None:
|
|
210
|
+
var_dsg_id = self.cmd.color_variableid
|
|
211
|
+
var_cmd = self.session.variables[var_dsg_id]
|
|
212
|
+
v_min = None
|
|
213
|
+
v_max = None
|
|
214
|
+
for lvl in var_cmd.levels:
|
|
215
|
+
if (v_min is None) or (v_min > lvl.value):
|
|
216
|
+
v_min = lvl.value
|
|
217
|
+
if (v_max is None) or (v_max < lvl.value):
|
|
218
|
+
v_max = lvl.value
|
|
219
|
+
var_minmax = [v_min, v_max]
|
|
220
|
+
# build a power of two x 1 texture
|
|
221
|
+
num_texels = int(len(var_cmd.texture) / 4)
|
|
222
|
+
half_texel = 1 / (num_texels * 2.0)
|
|
223
|
+
num_verts = int(verts.size / 3)
|
|
224
|
+
tmp = numpy.ndarray((num_verts * 2,), dtype="float32")
|
|
225
|
+
tmp.fill(0.5) # fill in the T coordinate...
|
|
226
|
+
tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels
|
|
227
|
+
# if the range is 0, adjust the min by -1. The result is that the texture
|
|
228
|
+
# coords will get mapped to S=1.0 which is what EnSight does in this situation
|
|
229
|
+
if (var_minmax[1] - var_minmax[0]) == 0.0:
|
|
230
|
+
var_minmax[0] = var_minmax[0] - 1.0
|
|
231
|
+
var_width = var_minmax[1] - var_minmax[0]
|
|
232
|
+
for idx in range(num_verts):
|
|
233
|
+
# normalized S coord value (clamp)
|
|
234
|
+
s = (tcoords[idx] - var_minmax[0]) / var_width
|
|
235
|
+
if s < 0.0:
|
|
236
|
+
s = 0.0
|
|
237
|
+
if s > 1.0:
|
|
238
|
+
s = 1.0
|
|
239
|
+
# map to the texture range and set the S value
|
|
240
|
+
tmp[idx * 2] = s * tex_width + half_texel
|
|
241
|
+
tcoords = tmp
|
|
242
|
+
|
|
243
|
+
self.session.log(
|
|
244
|
+
f"Part '{self.cmd.name}' defined: {self.coords.size/3} verts, {self.conn_tris.size/3} tris, {self.conn_lines.size/2} lines."
|
|
245
|
+
)
|
|
246
|
+
command = self.cmd
|
|
247
|
+
|
|
248
|
+
return command, verts, conn, normals, tcoords, var_cmd
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class UpdateHandler(object):
|
|
252
|
+
"""
|
|
253
|
+
This class serves as the interface between a DSGSession and a hosting application.
|
|
254
|
+
The DSGSession processes the general aspects of the gRPC pipeline and collects the
|
|
255
|
+
various DSG objects into collections of: groups, variables, etc. It also coalesces
|
|
256
|
+
the individual array updates into a "Part" object which represents a single addressable
|
|
257
|
+
mesh chunk.
|
|
258
|
+
UpdateHandler methods are called as the various update happen, and it is called when
|
|
259
|
+
a mesh chunk has been entirely defined. In most scenarios, a subclass of UpdateHandler
|
|
260
|
+
is passed to the DSGSession to handshake the mesh data to the application target.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self) -> None:
|
|
264
|
+
self._session: "DSGSession"
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def session(self) -> "DSGSession":
|
|
268
|
+
"""The session object this handler has been associated with"""
|
|
269
|
+
return self._session
|
|
270
|
+
|
|
271
|
+
@session.setter
|
|
272
|
+
def session(self, session: "DSGSession") -> None:
|
|
273
|
+
self._session = session
|
|
274
|
+
|
|
275
|
+
def add_group(self, id: int, view: bool = False) -> None:
|
|
276
|
+
"""Called when a new group command has been added: self.session.groups[id]"""
|
|
277
|
+
if view:
|
|
278
|
+
self.session.log(f"Adding view: {self.session.groups[id]}")
|
|
279
|
+
else:
|
|
280
|
+
self.session.log(f"Adding group: {self.session.groups[id].name}")
|
|
281
|
+
|
|
282
|
+
def add_variable(self, id: int) -> None:
|
|
283
|
+
"""Called when a new group command has been added: self.session.variables[id]"""
|
|
284
|
+
self.session.log(f"Adding variable: {self.session.variables[id].name}")
|
|
285
|
+
|
|
286
|
+
def finalize_part(self, part: Part) -> None:
|
|
287
|
+
"""Called when all the updates on a Part object have been completed.
|
|
288
|
+
Note: this should be called after the subclass has processed the part
|
|
289
|
+
as the part command will be destroyed by this call.
|
|
290
|
+
"""
|
|
291
|
+
if part.cmd:
|
|
292
|
+
self.session.log(f"Part finalized: {part.cmd.name}")
|
|
293
|
+
part.cmd = None
|
|
294
|
+
|
|
295
|
+
def start_connection(self) -> None:
|
|
296
|
+
"""A new gRPC connection has been established: self.session.grpc"""
|
|
297
|
+
grpc = self.session.grpc
|
|
298
|
+
self.session.log(f"gRPC connection established to: {grpc.host}:{grpc.port}")
|
|
299
|
+
|
|
300
|
+
def end_connection(self) -> None:
|
|
301
|
+
"""The previous gRPC connection has been closed"""
|
|
302
|
+
self.session.log("gRPC connection closed")
|
|
303
|
+
|
|
304
|
+
def begin_update(self) -> None:
|
|
305
|
+
"""A new scene update is about to begin"""
|
|
306
|
+
self.session.log("Begin update ------------------------")
|
|
307
|
+
|
|
308
|
+
def end_update(self) -> None:
|
|
309
|
+
"""The scene update is complete"""
|
|
310
|
+
self.session.log("End update ------------------------")
|
|
311
|
+
|
|
312
|
+
def get_dsg_cmd_attribute(self, obj: Any, name: str, default: Any = None) -> Optional[str]:
|
|
313
|
+
"""Utility function to get an attribute from a DSG update object
|
|
314
|
+
|
|
315
|
+
Note: UpdateVariable and UpdateGroup commands support generic attributes
|
|
316
|
+
"""
|
|
317
|
+
return obj.attributes.get(name, default)
|
|
318
|
+
|
|
319
|
+
def group_matrix(self, group: Any) -> Any:
|
|
320
|
+
matrix = group.matrix4x4
|
|
321
|
+
# The Case matrix is basically the camera transform. In vrmode, we only want
|
|
322
|
+
# the raw geometry, so use the identity matrix.
|
|
323
|
+
if (
|
|
324
|
+
self.get_dsg_cmd_attribute(group, "ENS_OBJ_TYPE") == "ENS_CASE"
|
|
325
|
+
) and self.session.vrmode:
|
|
326
|
+
matrix = [
|
|
327
|
+
1.0,
|
|
328
|
+
0.0,
|
|
329
|
+
0.0,
|
|
330
|
+
0.0,
|
|
331
|
+
0.0,
|
|
332
|
+
1.0,
|
|
333
|
+
0.0,
|
|
334
|
+
0.0,
|
|
335
|
+
0.0,
|
|
336
|
+
0.0,
|
|
337
|
+
1.0,
|
|
338
|
+
0.0,
|
|
339
|
+
0.0,
|
|
340
|
+
0.0,
|
|
341
|
+
0.0,
|
|
342
|
+
1.0,
|
|
343
|
+
]
|
|
344
|
+
return matrix
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class DSGSession(object):
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
port: int = 12345,
|
|
351
|
+
host: str = "127.0.0.1",
|
|
352
|
+
security_code: str = "",
|
|
353
|
+
verbose: int = 0,
|
|
354
|
+
normalize_geometry: bool = False,
|
|
355
|
+
vrmode: bool = False,
|
|
356
|
+
handler: UpdateHandler = UpdateHandler(),
|
|
357
|
+
):
|
|
358
|
+
"""
|
|
359
|
+
Manage a gRPC connection and link it to an UpdateHandler instance
|
|
360
|
+
|
|
361
|
+
This class makes a DSG gRPC connection via the specified port and host (leveraging
|
|
362
|
+
the passed security code). As DSG protobuffers arrive, they are merged into Part
|
|
363
|
+
object instances and the UpdateHandler is invoked to further process them.
|
|
364
|
+
|
|
365
|
+
Parameters
|
|
366
|
+
----------
|
|
367
|
+
port : int
|
|
368
|
+
The port number the EnSight gRPC service is running on.
|
|
369
|
+
The default is ``12345``.
|
|
370
|
+
host : str
|
|
371
|
+
Name of the host that the EnSight gRPC service is running on.
|
|
372
|
+
The default is ``"127.0.0.1"``, which is the localhost.
|
|
373
|
+
security_code : str
|
|
374
|
+
Shared security code for validating the gRPC communication.
|
|
375
|
+
The default is ``""``.
|
|
376
|
+
verbose : int
|
|
377
|
+
The verbosity level. If set to 1 or higher the class will call logging.info
|
|
378
|
+
for log output. The default is ``0``.
|
|
379
|
+
normalize_geometry : bool
|
|
380
|
+
If True, the scene coordinates will be remapped into the volume [-1,-1,-1] - [1,1,1]
|
|
381
|
+
The default is not to remap coordinates.
|
|
382
|
+
vrmode : bool
|
|
383
|
+
If True, do not include the EnSight camera in the generated view group. The default
|
|
384
|
+
is to include the EnSight view in the scene transformations.
|
|
385
|
+
handler : UpdateHandler
|
|
386
|
+
This is an UpdateHandler subclass that is called back when the state of
|
|
387
|
+
a scene transfer changes. For example, methods are called when the
|
|
388
|
+
transfer begins or ends and when a Part (mesh block) is ready for processing.
|
|
389
|
+
"""
|
|
390
|
+
super().__init__()
|
|
391
|
+
self._grpc = ensight_grpc.EnSightGRPC(port=port, host=host, secret_key=security_code)
|
|
392
|
+
self._callback_handler = handler
|
|
393
|
+
self._verbose = verbose
|
|
394
|
+
self._thread: Optional[threading.Thread] = None
|
|
395
|
+
self._message_queue: queue.Queue = queue.Queue() # Messages coming from EnSight
|
|
396
|
+
self._dsg_queue: Optional[queue.SimpleQueue] = None # Outgoing messages to EnSight
|
|
397
|
+
self._shutdown = False
|
|
398
|
+
self._dsg = None
|
|
399
|
+
self._normalize_geometry = normalize_geometry
|
|
400
|
+
self._vrmode = vrmode
|
|
401
|
+
self._mesh_block_count = 0
|
|
402
|
+
self._variables: Dict[int, Any] = dict()
|
|
403
|
+
self._groups: Dict[int, Any] = dict()
|
|
404
|
+
self._part: Part = Part(self)
|
|
405
|
+
self._scene_bounds: Optional[List] = None
|
|
406
|
+
self._callback_handler.session = self
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def scene_bounds(self) -> Optional[List]:
|
|
410
|
+
return self._scene_bounds
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def mesh_block_count(self) -> int:
|
|
414
|
+
return self._mesh_block_count
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def vrmode(self) -> bool:
|
|
418
|
+
return self._vrmode
|
|
419
|
+
|
|
420
|
+
@vrmode.setter
|
|
421
|
+
def vrmode(self, value: bool) -> None:
|
|
422
|
+
self._vrmode = value
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def normalize_geometry(self) -> bool:
|
|
426
|
+
return self._normalize_geometry
|
|
427
|
+
|
|
428
|
+
@normalize_geometry.setter
|
|
429
|
+
def normalize_geometry(self, value: bool) -> None:
|
|
430
|
+
self._normalize_geometry = value
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def variables(self) -> dict:
|
|
434
|
+
return self._variables
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def groups(self) -> dict:
|
|
438
|
+
return self._groups
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def part(self) -> Part:
|
|
442
|
+
return self._part
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def grpc(self) -> ensight_grpc.EnSightGRPC:
|
|
446
|
+
return self._grpc
|
|
447
|
+
|
|
448
|
+
def log(self, s: str, level: int = 0) -> None:
|
|
449
|
+
"""Log a string to the logging system
|
|
450
|
+
|
|
451
|
+
If the message level is less than the current verbosity,
|
|
452
|
+
emit the message.
|
|
453
|
+
"""
|
|
454
|
+
if level < self._verbose:
|
|
455
|
+
logging.info(s)
|
|
456
|
+
|
|
457
|
+
def start(self) -> int:
|
|
458
|
+
"""Start a gRPC connection to an EnSight instance
|
|
459
|
+
|
|
460
|
+
Make a gRPC connection and start a DSG stream handler.
|
|
461
|
+
|
|
462
|
+
Returns
|
|
463
|
+
-------
|
|
464
|
+
0 on success, -1 on an error.
|
|
465
|
+
"""
|
|
466
|
+
# Start by setting up and verifying the connection
|
|
467
|
+
self._grpc.connect()
|
|
468
|
+
if not self._grpc.is_connected():
|
|
469
|
+
self.log(f"Unable to establish gRPC connection to: {self._grpc.host}:{self._grpc.port}")
|
|
470
|
+
return -1
|
|
471
|
+
# Streaming API requires an iterator, so we make one from a queue
|
|
472
|
+
# it also returns an iterator. self._dsg_queue is the input stream interface
|
|
473
|
+
# self._dsg is the returned stream iterator.
|
|
474
|
+
if self._dsg is not None:
|
|
475
|
+
return 0
|
|
476
|
+
self._dsg_queue = queue.SimpleQueue()
|
|
477
|
+
self._dsg = self._grpc.dynamic_scene_graph_stream(
|
|
478
|
+
iter(self._dsg_queue.get, None) # type:ignore
|
|
479
|
+
)
|
|
480
|
+
self._thread = threading.Thread(target=self._poll_messages)
|
|
481
|
+
if self._thread is not None:
|
|
482
|
+
self._thread.start()
|
|
483
|
+
self._callback_handler.start_connection()
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
def end(self):
|
|
487
|
+
"""Stop a gRPC connection to the EnSight instance"""
|
|
488
|
+
self._callback_handler.end_connection()
|
|
489
|
+
self._grpc.stop_server()
|
|
490
|
+
self._shutdown = True
|
|
491
|
+
self._thread.join()
|
|
492
|
+
self._grpc.shutdown()
|
|
493
|
+
self._dsg = None
|
|
494
|
+
self._thread = None
|
|
495
|
+
self._dsg_queue = None
|
|
496
|
+
|
|
497
|
+
def is_shutdown(self):
|
|
498
|
+
"""Check the service shutdown request status"""
|
|
499
|
+
return self._shutdown
|
|
500
|
+
|
|
501
|
+
def request_an_update(self, animation: bool = False, allow_spontaneous: bool = True) -> None:
|
|
502
|
+
"""Start a DSG update
|
|
503
|
+
Send a command to the DSG protocol to "init" an update.
|
|
504
|
+
|
|
505
|
+
Parameters
|
|
506
|
+
----------
|
|
507
|
+
animation:
|
|
508
|
+
if True, export all EnSight timesteps.
|
|
509
|
+
allow_spontaneous:
|
|
510
|
+
if True, allow EnSight to trigger async updates.
|
|
511
|
+
"""
|
|
512
|
+
# Send an INIT command to trigger a stream of update packets
|
|
513
|
+
cmd = dynamic_scene_graph_pb2.SceneClientCommand()
|
|
514
|
+
cmd.command_type = dynamic_scene_graph_pb2.SceneClientCommand.INIT
|
|
515
|
+
# Allow EnSight push commands, but full scene only for now...
|
|
516
|
+
cmd.init.allow_spontaneous = allow_spontaneous
|
|
517
|
+
cmd.init.include_temporal_geometry = animation
|
|
518
|
+
cmd.init.allow_incremental_updates = False
|
|
519
|
+
cmd.init.maximum_chunk_size = 1024 * 1024
|
|
520
|
+
self._dsg_queue.put(cmd) # type:ignore
|
|
521
|
+
# Handle the update messages
|
|
522
|
+
self.handle_one_update()
|
|
523
|
+
|
|
524
|
+
def _poll_messages(self) -> None:
|
|
525
|
+
"""Core interface to grab DSG events from gRPC and queue them for processing
|
|
526
|
+
|
|
527
|
+
This is run by a thread that is monitoring the dsg RPC call for update messages
|
|
528
|
+
it places them in _message_queue as it finds them. They are picked up by the
|
|
529
|
+
main thread via get_next_message()
|
|
530
|
+
"""
|
|
531
|
+
while not self._shutdown:
|
|
532
|
+
try:
|
|
533
|
+
self._message_queue.put(next(self._dsg)) # type:ignore
|
|
534
|
+
except Exception:
|
|
535
|
+
self._shutdown = True
|
|
536
|
+
self.log("DSG connection broken, calling exit")
|
|
537
|
+
os._exit(0)
|
|
538
|
+
|
|
539
|
+
def _get_next_message(self, wait: bool = True) -> Any:
|
|
540
|
+
"""Get the next queued up protobuffer message
|
|
541
|
+
|
|
542
|
+
Called by the main thread to get any messages that were pulled in from the
|
|
543
|
+
dsg stream and placed here by _poll_messages()
|
|
544
|
+
"""
|
|
545
|
+
try:
|
|
546
|
+
return self._message_queue.get(block=wait)
|
|
547
|
+
except queue.Empty:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
def handle_one_update(self) -> None:
|
|
551
|
+
"""Monitor the DSG stream and handle a single update operation
|
|
552
|
+
|
|
553
|
+
Wait until we get the scene update begin message. From there, reset the current
|
|
554
|
+
scene buckets and then parse all the incoming commands until we get the scene
|
|
555
|
+
update end command. At which point, save the generated stage (started in the
|
|
556
|
+
view command handler). Note: Parts are handled with an available bucket at all times.
|
|
557
|
+
When a new part update comes in or the scene update end happens, the part is "finished".
|
|
558
|
+
"""
|
|
559
|
+
# An update starts with a UPDATE_SCENE_BEGIN command
|
|
560
|
+
cmd = self._get_next_message()
|
|
561
|
+
while (cmd is not None) and (
|
|
562
|
+
cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_BEGIN
|
|
563
|
+
):
|
|
564
|
+
# Look for a begin command
|
|
565
|
+
cmd = self._get_next_message()
|
|
566
|
+
|
|
567
|
+
# Start anew
|
|
568
|
+
self._variables = {}
|
|
569
|
+
self._groups = {}
|
|
570
|
+
self._part = Part(self)
|
|
571
|
+
self._scene_bounds = None
|
|
572
|
+
self._mesh_block_count = 0 # reset when a new group shows up
|
|
573
|
+
self._callback_handler.begin_update()
|
|
574
|
+
|
|
575
|
+
# handle the various commands until UPDATE_SCENE_END
|
|
576
|
+
cmd = self._get_next_message()
|
|
577
|
+
while (cmd is not None) and (
|
|
578
|
+
cmd.command_type != dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_SCENE_END
|
|
579
|
+
):
|
|
580
|
+
self._handle_update_command(cmd)
|
|
581
|
+
cmd = self._get_next_message()
|
|
582
|
+
|
|
583
|
+
# Flush the last part
|
|
584
|
+
self._finish_part()
|
|
585
|
+
|
|
586
|
+
self._callback_handler.end_update()
|
|
587
|
+
|
|
588
|
+
def _handle_update_command(self, cmd: dynamic_scene_graph_pb2.SceneUpdateCommand) -> None:
|
|
589
|
+
"""Dispatch out a scene update command to the proper handler
|
|
590
|
+
|
|
591
|
+
Given a command object, pull out the correct portion of the protobuffer union and
|
|
592
|
+
pass it to the appropriate handler.
|
|
593
|
+
|
|
594
|
+
Parameters
|
|
595
|
+
----------
|
|
596
|
+
cmd:
|
|
597
|
+
The command to be dispatched.
|
|
598
|
+
"""
|
|
599
|
+
name = "Unknown"
|
|
600
|
+
if cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.DELETE_ID:
|
|
601
|
+
name = "Delete IDs"
|
|
602
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_PART:
|
|
603
|
+
name = "Part update"
|
|
604
|
+
tmp = cmd.update_part
|
|
605
|
+
self._handle_part(tmp)
|
|
606
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GROUP:
|
|
607
|
+
name = "Group update"
|
|
608
|
+
tmp = cmd.update_group
|
|
609
|
+
self._handle_group(tmp)
|
|
610
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_GEOM:
|
|
611
|
+
name = "Geom update"
|
|
612
|
+
tmp = cmd.update_geom
|
|
613
|
+
self._part.update_geom(tmp)
|
|
614
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VARIABLE:
|
|
615
|
+
name = "Variable update"
|
|
616
|
+
tmp = cmd.update_variable
|
|
617
|
+
self._handle_variable(tmp)
|
|
618
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_VIEW:
|
|
619
|
+
name = "View update"
|
|
620
|
+
tmp = cmd.update_view
|
|
621
|
+
self._handle_view(tmp)
|
|
622
|
+
elif cmd.command_type == dynamic_scene_graph_pb2.SceneUpdateCommand.UPDATE_TEXTURE:
|
|
623
|
+
name = "Texture update"
|
|
624
|
+
self.log(f"{name} --------------------------")
|
|
625
|
+
|
|
626
|
+
def _finish_part(self) -> None:
|
|
627
|
+
"""Complete the current part
|
|
628
|
+
|
|
629
|
+
There is always a part being modified. This method completes the current part, committing
|
|
630
|
+
it to the handler.
|
|
631
|
+
"""
|
|
632
|
+
self._part.build()
|
|
633
|
+
self._callback_handler.finalize_part(self.part)
|
|
634
|
+
self._mesh_block_count += 1
|
|
635
|
+
|
|
636
|
+
def _handle_part(self, part: Any) -> None:
|
|
637
|
+
"""Handle a DSG UPDATE_PART command
|
|
638
|
+
|
|
639
|
+
Finish the current part and set up the next part.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
part:
|
|
644
|
+
The command coming from the EnSight stream.
|
|
645
|
+
"""
|
|
646
|
+
self._finish_part()
|
|
647
|
+
self._part.reset(part)
|
|
648
|
+
|
|
649
|
+
def _handle_group(self, group: Any) -> None:
|
|
650
|
+
"""Handle a DSG UPDATE_GROUP command
|
|
651
|
+
|
|
652
|
+
Parameters
|
|
653
|
+
----------
|
|
654
|
+
group:
|
|
655
|
+
The command coming from the EnSight stream.
|
|
656
|
+
"""
|
|
657
|
+
# reset current mesh (part) count for unique "part" naming in USD
|
|
658
|
+
self._mesh_block_count = 0
|
|
659
|
+
|
|
660
|
+
# record the scene bounds in case they are needed later
|
|
661
|
+
self._groups[group.id] = group
|
|
662
|
+
bounds = group.attributes.get("ENS_SCENE_BOUNDS", None)
|
|
663
|
+
if bounds:
|
|
664
|
+
minmax = list()
|
|
665
|
+
for v in bounds.split(","):
|
|
666
|
+
try:
|
|
667
|
+
minmax.append(float(v))
|
|
668
|
+
except ValueError:
|
|
669
|
+
pass
|
|
670
|
+
if len(minmax) == 6:
|
|
671
|
+
self._scene_bounds = minmax
|
|
672
|
+
# callback
|
|
673
|
+
self._callback_handler.add_group(group.id)
|
|
674
|
+
|
|
675
|
+
def _handle_variable(self, var: Any) -> None:
|
|
676
|
+
"""Handle a DSG UPDATE_VARIABLE command
|
|
677
|
+
|
|
678
|
+
Save off the EnSight variable DSG command object.
|
|
679
|
+
|
|
680
|
+
Parameters
|
|
681
|
+
----------
|
|
682
|
+
var:
|
|
683
|
+
The command coming from the EnSight stream.
|
|
684
|
+
"""
|
|
685
|
+
self._variables[var.id] = var
|
|
686
|
+
self._callback_handler.add_variable(var.id)
|
|
687
|
+
|
|
688
|
+
def _handle_view(self, view: Any) -> None:
|
|
689
|
+
"""Handle a DSG UPDATE_VIEW command
|
|
690
|
+
|
|
691
|
+
Parameters
|
|
692
|
+
----------s
|
|
693
|
+
view:
|
|
694
|
+
The command coming from the EnSight stream.
|
|
695
|
+
"""
|
|
696
|
+
self._scene_bounds = None
|
|
697
|
+
self._groups[view.id] = view
|
|
698
|
+
self._callback_handler.add_group(view.id, view=True)
|