diff --git a/src/ansys/pyensight/core/utils/dsg_server.py b/src/ansys/pyensight/core/utils/dsg_server.py index 81110b00177..a181992a924 100644 --- a/src/ansys/pyensight/core/utils/dsg_server.py +++ b/src/ansys/pyensight/core/utils/dsg_server.py @@ -142,7 +142,7 @@ def nodal_surface_rep(self): self.session.log(f"Note: part '{self.cmd.name}' contains no triangles.") return None, None, None, None, None, None verts = self.coords - self.normalize_verts(verts) + _ = self._normalize_verts(verts) conn = self.conn_tris normals = self.normals @@ -151,7 +151,7 @@ def nodal_surface_rep(self): tcoords = self.tcoords if self.tcoords_elem or self.normals_elem: verts_per_prim = 3 - num_prims = int(conn.size / verts_per_prim) + num_prims = conn.size // verts_per_prim # "flatten" the triangles to move values from elements to nodes new_verts = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32") new_conn = numpy.ndarray((num_prims * verts_per_prim,), dtype="int32") @@ -205,47 +205,16 @@ def nodal_surface_rep(self): var_cmd = None # texture coords need transformation from variable value to [ST] if tcoords is not None: - var_dsg_id = self.cmd.color_variableid - var_cmd = self.session.variables[var_dsg_id] - v_min = None - v_max = None - for lvl in var_cmd.levels: - if (v_min is None) or (v_min > lvl.value): - v_min = lvl.value - if (v_max is None) or (v_max < lvl.value): - v_max = lvl.value - var_minmax = [v_min, v_max] - # build a power of two x 1 texture - num_texels = int(len(var_cmd.texture) / 4) - half_texel = 1 / (num_texels * 2.0) - num_verts = int(verts.size / 3) - tmp = numpy.ndarray((num_verts * 2,), dtype="float32") - tmp.fill(0.5) # fill in the T coordinate... - tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels - # if the range is 0, adjust the min by -1. The result is that the texture - # coords will get mapped to S=1.0 which is what EnSight does in this situation - if (var_minmax[1] - var_minmax[0]) == 0.0: - var_minmax[0] = var_minmax[0] - 1.0 - var_width = var_minmax[1] - var_minmax[0] - for idx in range(num_verts): - # normalized S coord value (clamp) - s = (tcoords[idx] - var_minmax[0]) / var_width - if s < 0.0: - s = 0.0 - if s > 1.0: - s = 1.0 - # map to the texture range and set the S value - tmp[idx * 2] = s * tex_width + half_texel - tcoords = tmp + tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3) self.session.log( - f"Part '{self.cmd.name}' defined: {self.coords.size/3} verts, {self.conn_tris.size/3} tris." + f"Part '{self.cmd.name}' defined: {self.coords.size // 3} verts, {self.conn_tris.size // 3} tris." ) command = self.cmd return command, verts, conn, normals, tcoords, var_cmd - def normalize_verts(self, verts: numpy.ndarray): + def _normalize_verts(self, verts: numpy.ndarray): """ This function scales and translates vertices, so the longest axis in the scene is of length 1.0, and data is centered at the origin @@ -254,7 +223,7 @@ def normalize_verts(self, verts: numpy.ndarray): """ s = 1.0 if self.session.normalize_geometry and self.session.scene_bounds is not None: - num_verts = int(verts.size / 3) + num_verts = verts.size // 3 midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5 midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5 midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5 @@ -275,6 +244,118 @@ def normalize_verts(self, verts: numpy.ndarray): verts[j + 2] = (verts[j + 2] - midz) / s return 1.0 / s + def _build_st_coords(self, tcoords: numpy.ndarray, num_verts: int): + """ + The Omniverse interface uses 2D texturing (s,t) to reference the texture map. + This method converts DSG texture coordinates (1D and in "variable" units) into + 2D OpenGL style [0.,1.] normalized coordinate space. the "t" coordinate will + always be 0.5. + + Parameters + ---------- + tcoords: numpy.ndarray + The DSG 1D texture coordinates, which are actually variable values. + + num_verts: int + The number of vertices in the mesh. + + Returns + ------- + numpy.ndarray, Any + The ST OpenGL GL texture coordinate array and the variable definition DSG command. + """ + var_dsg_id = self.cmd.color_variableid # type: ignore + var_cmd = self.session.variables[var_dsg_id] + v_min = None + v_max = None + for lvl in var_cmd.levels: + if (v_min is None) or (v_min > lvl.value): + v_min = lvl.value + if (v_max is None) or (v_max < lvl.value): + v_max = lvl.value + var_minmax: List[float] = [v_min, v_max] # type: ignore + # build a power of two x 1 texture + num_texels = len(var_cmd.texture) // 4 + half_texel = 1 / (num_texels * 2.0) + tmp = numpy.ndarray((num_verts * 2,), dtype="float32") + tmp.fill(0.5) # fill in the T coordinate... + tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels + # if the range is 0, adjust the min by -1. The result is that the texture + # coords will get mapped to S=1.0 which is what EnSight does in this situation + if (var_minmax[1] - var_minmax[0]) == 0.0: + var_minmax[0] = var_minmax[0] - 1.0 + var_width = var_minmax[1] - var_minmax[0] + for idx in range(num_verts): + # normalized S coord value (clamp) + s = (tcoords[idx] - var_minmax[0]) / var_width + if s < 0.0: + s = 0.0 + if s > 1.0: + s = 1.0 + # map to the texture range and set the S value + tmp[idx * 2] = s * tex_width + half_texel + return tmp, var_cmd + + def line_rep(self): + """ + This function processes the geometry arrays and returns values to represent line data. + The vertex array embeds the connectivity, so every two points represent a line segment. + The tcoords similarly follow the vertex array notion. + + Returns + ------- + On failure, the method returns None for the first return value. The returned tuple is: + + (part_command, vertices, connectivity, tex_coords, var_command) + + part_command: UPDATE_PART command object + vertices: numpy array of per-node coordinates (two per line segment) + tcoords: numpy array of per vertex texture coordinates (optional) + var_command: UPDATE_VARIABLE command object for the variable the colors correspond to, if any + """ + if self.cmd is None: + return None, None, None, None + if self.cmd.render != self.cmd.CONNECTIVITY: + # Early out. Rendering type for this object is a surface rep, not a point rep + return None, None, None, None + + num_lines = self.conn_lines.size // 2 + if num_lines == 0: + return None, None, None, None + verts = numpy.ndarray((num_lines * 2 * 3,), dtype="float32") + tcoords = None + if self.tcoords.size: + tcoords = numpy.ndarray((num_lines * 2,), dtype="float32") + # TODO: handle elemental line values (self.tcoords_elem) by converting to nodal... + # if self.tcoords_elem: + for i in range(num_lines): + i0 = self.conn_lines[i * 2] + i1 = self.conn_lines[i * 2 + 1] + offset = i * 6 + verts[offset + 0] = self.coords[i0 * 3 + 0] + verts[offset + 1] = self.coords[i0 * 3 + 1] + verts[offset + 2] = self.coords[i0 * 3 + 2] + verts[offset + 3] = self.coords[i1 * 3 + 0] + verts[offset + 4] = self.coords[i1 * 3 + 1] + verts[offset + 5] = self.coords[i1 * 3 + 2] + if tcoords is not None: + # tcoords are 1D at this point + offset = i * 2 + tcoords[offset + 0] = self.tcoords[i0] + tcoords[offset + 1] = self.tcoords[i1] + + _ = self._normalize_verts(verts) + + var_cmd = None + # texture coords need transformation from variable value to [ST] + if tcoords is not None: + tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3) + + self.session.log(f"Part '{self.cmd.name}' defined: {num_lines} lines.") + command = self.cmd + + return command, verts, tcoords, var_cmd + def point_rep(self): """ This function processes the geometry arrays and returns values to represent point data @@ -297,8 +378,8 @@ def point_rep(self): # Early out. Rendering type for this object is a surface rep, not a point rep return None, None, None, None, None verts = self.coords - num_verts = int(verts.size / 3) - norm_scale = self.normalize_verts(verts) + num_verts = verts.size // 3 + norm_scale = self._normalize_verts(verts) # Convert var values in self.tcoords to RGB colors # For now, look up RGB colors. Planned USD enhancements should allow tex coords instead. @@ -359,7 +440,7 @@ def point_rep(self): colors[idx * 3 + ii] = ( col0[ii] * pal_sub + col1[ii] * (1.0 - pal_sub) ) / 255.0 - self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.") + self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.") node_sizes = None if self.node_sizes.size and self.node_sizes.size == num_verts: @@ -375,7 +456,7 @@ def point_rep(self): for ii in range(0, num_verts): node_sizes[ii] = node_size_default - self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.") + self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.") command = self.cmd return command, verts, node_sizes, colors, var_cmd diff --git a/src/ansys/pyensight/core/utils/omniverse_cli.py b/src/ansys/pyensight/core/utils/omniverse_cli.py index 1e8056e0658..9d7f3a8481e 100644 --- a/src/ansys/pyensight/core/utils/omniverse_cli.py +++ b/src/ansys/pyensight/core/utils/omniverse_cli.py @@ -78,6 +78,8 @@ def __init__( normalize_geometry: bool = False, dsg_uri: str = "", monitor_directory: str = "", + line_width: float = -0.0001, + use_lines: bool = False, ) -> None: self._dsg_uri = dsg_uri self._destination = destination @@ -93,6 +95,8 @@ def __init__( self._server_process = None self._status_filename: str = "" self._monitor_directory: str = monitor_directory + self._line_width = line_width + self._use_lines = use_lines @property def monitor_directory(self) -> Optional[str]: @@ -184,7 +188,9 @@ def run_server(self, one_shot: bool = False) -> None: """ # Build the Omniverse connection - omni_link = ov_dsg_server.OmniverseWrapper(destination=self._destination) + omni_link = ov_dsg_server.OmniverseWrapper( + destination=self._destination, line_width=self._line_width, use_lines=self._use_lines + ) logging.info("Omniverse connection established.") # parse the DSG USI @@ -269,7 +275,9 @@ def run_monitor(self): return # Build the Omniverse connection - omni_link = ov_dsg_server.OmniverseWrapper(destination=self._destination) + omni_link = ov_dsg_server.OmniverseWrapper( + destination=self._destination, line_width=self._line_width, use_lines=self._use_lines + ) logging.info("Omniverse connection established.") # use an OmniverseUpdateHandler @@ -449,6 +457,20 @@ def run_monitor(self): type=str2bool_type, help="Convert a single geometry into USD and exit. Default: false", ) + line_default: Any = os.environ.get("ANSYS_OV_LINE_WIDTH", None) + if line_default is not None: + try: + line_default = float(line_default) + except ValueError: + line_default = None + # Potential future default: -0.0001 + parser.add_argument( + "--line_width", + metavar="line_width", + default=line_default, + type=float, + help=f"Width of lines: >0=absolute size. <0=fraction of diagonal. 0=wireframe. Default: {line_default}", + ) # parse the command line args = parser.parse_args() @@ -469,6 +491,12 @@ def run_monitor(self): logging.root.removeHandler(logging.root.handlers[0]) logging.basicConfig(**log_args) # type: ignore + # size of lines in data units or fraction of bounding box diagonal + use_lines = args.line_width is not None + line_width = -0.0001 + if args.line_width is not None: + line_width = args.line_width + # Build the server object server = OmniverseGeometryServer( destination=args.destination, @@ -479,6 +507,8 @@ def run_monitor(self): normalize_geometry=args.normalize_geometry, vrmode=not args.include_camera, temporal=args.temporal, + line_width=line_width, + use_lines=use_lines, ) # run the server diff --git a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py index 5786d8e662c..fb4605f6661 100644 --- a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py +++ b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py @@ -32,12 +32,19 @@ from typing import Any, Dict, List, Optional from ansys.pyensight.core.utils.dsg_server import Part, UpdateHandler +import numpy import png from pxr import Gf, Sdf, Usd, UsdGeom, UsdLux, UsdShade class OmniverseWrapper(object): - def __init__(self, live_edit: bool = False, destination: str = "") -> None: + def __init__( + self, + live_edit: bool = False, + destination: str = "", + line_width: float = -0.0001, + use_lines: bool = False, + ) -> None: self._cleaned_index = 0 self._cleaned_names: dict = {} self._connectionStatusSubscription = None @@ -54,6 +61,9 @@ def __init__(self, live_edit: bool = False, destination: str = "") -> None: if destination: self.destination = destination + self._line_width = line_width + self._use_lines = use_lines + @property def destination(self) -> str: """The current output directory.""" @@ -65,6 +75,18 @@ def destination(self, directory: str) -> None: if not self.is_valid_destination(directory): logging.warning(f"Invalid destination path: {directory}") + @property + def line_width(self) -> float: + return self._line_width + + @line_width.setter + def line_width(self, line_width: float) -> None: + self._line_width = line_width + + @property + def use_lines(self) -> bool: + return self._use_lines + def shutdown(self) -> None: """ Shutdown the connection to Omniverse cleanly. @@ -274,7 +296,7 @@ def create_dsg_mesh_block( mesh.CreateDoubleSidedAttr().Set(True) mesh.CreatePointsAttr(verts) mesh.CreateNormalsAttr(normals) - mesh.CreateFaceVertexCountsAttr([3] * int(conn.size / 3)) + mesh.CreateFaceVertexCountsAttr([3] * (conn.size // 3)) mesh.CreateFaceVertexIndicesAttr(conn) if (tcoords is not None) and variable: # USD 22.08 changed the primvar API @@ -334,6 +356,100 @@ def add_timestep_group( visibility_attr.Set("invisible", timeline[1] * self._time_codes_per_second) return timestep_prim + def create_dsg_lines( + self, + name, + id, + part_hash, + parent_prim, + verts, + tcoords, + matrix=[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0], + diffuse=[1.0, 1.0, 1.0, 1.0], + variable=None, + timeline=[0.0, 0.0], + first_timestep=False, + ): + # 1D texture map for variables https://graphics.pixar.com/usd/release/tut_simple_shading.html + # create the part usd object + partname = self.clean_name(name + part_hash.hexdigest()) + stage_name = "/Parts/" + partname + ".usd" + part_stage_url = self.stage_url(os.path.join("Parts", partname + ".usd")) + part_stage = None + + # TODO: GLB extension maps to DSG PART attribute map + width = self.line_width + wireframe = width == 0.0 + if width < 0.0: + tmp = verts.reshape(-1, 3) + mins = numpy.min(tmp, axis=0) + maxs = numpy.max(tmp, axis=0) + dx = maxs[0] - mins[0] + dy = maxs[1] - mins[1] + dz = maxs[2] - mins[2] + diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) + width = diagonal * math.fabs(width) + self.line_width = width + + # For the present, only line colors are supported, no texturing + # var_cmd = variable + var_cmd = None + + if not os.path.exists(part_stage_url): + part_stage = Usd.Stage.CreateNew(part_stage_url) + self._old_stages.append(part_stage_url) + xform = UsdGeom.Xform.Define(part_stage, "/" + partname) + lines = UsdGeom.BasisCurves.Define(part_stage, "/" + partname + "/Lines") + lines.CreateDoubleSidedAttr().Set(True) + lines.CreatePointsAttr(verts) + lines.CreateCurveVertexCountsAttr([2] * (verts.size // 6)) + lines.CreatePurposeAttr().Set("render") + lines.CreateTypeAttr().Set("linear") + lines.CreateWidthsAttr([width]) + lines.SetWidthsInterpolation("constant") + prim = lines.GetPrim() + prim.CreateAttribute( + "omni:scene:visualization:drawWireframe", Sdf.ValueTypeNames.Bool + ).Set(wireframe) + if (tcoords is not None) and var_cmd: + # USD 22.08 changed the primvar API + if hasattr(lines, "CreatePrimvar"): + texCoords = lines.CreatePrimvar( + "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.varying + ) + else: + primvarsAPI = UsdGeom.PrimvarsAPI(lines) + texCoords = primvarsAPI.CreatePrimvar( + "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.varying + ) + texCoords.Set(tcoords) + texCoords.SetInterpolation("vertex") + part_prim = part_stage.GetPrimAtPath("/" + partname) + part_stage.SetDefaultPrim(part_prim) + + # Currently, this will never happen, but it is a setup for rigid body transforms + # At present, the group transforms have been cooked into the vertices so this is not needed + matrixOp = xform.AddXformOp( + UsdGeom.XformOp.TypeTransform, UsdGeom.XformOp.PrecisionDouble + ) + matrixOp.Set(Gf.Matrix4d(*matrix).GetTranspose()) + + self.create_dsg_material( + part_stage, lines, "/" + partname, diffuse=diffuse, variable=var_cmd + ) + + timestep_prim = self.add_timestep_group(parent_prim, timeline, first_timestep) + + # glue it into our stage + path = timestep_prim.GetPath().AppendChild("part_ref_" + partname) + part_ref = self._stage.OverridePrim(path) + part_ref.GetReferences().AddReference("." + stage_name) + + if part_stage is not None: + part_stage.GetRootLayer().Save() + + return part_stage_url + def create_dsg_points( self, name, @@ -645,6 +761,31 @@ def finalize_part(self, part: Part) -> None: timeline=self.session.cur_timeline, first_timestep=(self.session.cur_timeline[0] == self.session.time_limits[0]), ) + if self._omni.use_lines: + command, verts, tcoords, var_cmd = part.line_rep() + if command is not None: + line_color = [ + part.cmd.line_color[0] * part.cmd.diffuse, + part.cmd.line_color[1] * part.cmd.diffuse, + part.cmd.line_color[2] * part.cmd.diffuse, + part.cmd.line_color[3], + ] + # Generate the lines + _ = self._omni.create_dsg_lines( + name, + obj_id, + part.hash, + parent_prim, + verts, + tcoords, + matrix=matrix, + diffuse=line_color, + variable=var_cmd, + timeline=self.session.cur_timeline, + first_timestep=( + self.session.cur_timeline[0] == self.session.time_limits[0] + ), + ) elif part.cmd.render == part.cmd.NODES: command, verts, sizes, colors, var_cmd = part.point_rep()